diff --git a/Dockerfile b/Dockerfile index 61d16d9..5c8fe44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,7 @@ RUN apt-get update && \ apt-get clean && rm -rf /var/cache/apt/archives/* && rm -rf /var/lib/apt/lists/* RUN git clone --depth=1 https://github.com/vlang/v /opt/v && \ - cd /opt/v && \ - make && \ + make -C /opt/v && \ /opt/v/v symlink && \ v version @@ -36,4 +35,5 @@ WORKDIR /app USER 1000:1000 -ENV VMODULES=/tmp/.vmodules +ENV VMODULES=/tmp/vmodules +ENV VCACHE=/tmp/vcache diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..417e6bb --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,138 @@ +# Manual cross-compilation + +This repository contains ready-made scripts that allow you to describe the +desired results and obtain them simply by running `./make.vsh`. However, to +better understand the process, it's worth considering the manual build. +This file describes the algorithm of actions automated in the aforementioned +scripts. + +## Prepare the environment + +We want reproducible builds. We also don't want to clutter our computer with +things needed exclusively for cross-compilation. Besides manipulating certain +packages in the OS can inadvertently damage the system. + +Docker will help us achieve all our goals. +[Install it](https://docs.docker.com/get-started/get-docker/) if you haven't +already. Containers will ensure reproducible builds, as they will always run +in the same environment. + +## Let's begin + +Create a V programm. Just initialize empty V project in some dir: + +```console +$ mkdir crossv +$ cd crossv +$ v init +Input your project description: Cross-compilation example +Input your project version: (0.0.0) +Input your project license: (MIT) +Initialising ... +Created binary (application) project `crossv` +``` + +Contents of `main.v`: + +```v +module main + +fn main() { + println('Hello World!') +} +``` + +There is already an example Dockerfile in this repository, so here I will focus +on CLI. So let's run Debian Linux in container with current directory mounted: + +``` +docker run --rm -ti -v .:/app -w /app debian:trixie +``` + +See https://docs.docker.com/reference/cli/docker/container/run/ for details. + +Now we will run shell commands inside container. + +## Setup V compiler in container + +Install prerequisistes: + +``` +apt update +apt install -y --no-install-recommends --no-install-suggests build-essential git ca-certificates file +``` + +Download and bootstrap V compiler: + +``` +export VMODULES=/tmp/vmodules VCACHE=/tmp/vcache +git clone --depth=1 https://github.com/vlang/v /opt/v && make -C /opt/v && /opt/v/v symlink +``` + +After this `v` command should work. Try: + +``` +v version +``` + +## Cross-compile to ARM64 (AArch64) + +Your host is most likely an x86_64 computer. For the sake of example, let's +compile our Linux program for the AArch64 architecture. + +First we need to add build requirements. Debian already have an excellent +[cross-compiling](//wiki.debian.org/CrossCompiling) support. + +Prepate Debian package manager: + +``` +dpkg --add-architecture arm64 +apt update +``` + +We need to install `crossbuild-essential-arm64` package: + +``` +apt install -y --no-install-recommends --no-install-suggests crossbuild-essential-arm64 +``` + +Also there is packages for some other architectures which Debian supports: +https://packages.debian.org/search?keywords=crossbuild-essential&searchon=names&suite=stable§ion=all + +Now we have a GCC cross-compiler and some common libraries for ARM64. + +To compile out project just run: + +``` +v -prod -cc aarch64-linux-gnu-gcc -o hello . +``` + +Let's make sure we've built the correct executable using the `file` utility: + +```console +# file hello +hello: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=e9cfdee9abe5a80c304d489f243fbc60a22d93de, for GNU/Linux 3.7.0, not stripped +``` + +Binary is dynamically linked. To produce statically linked binary add `-cflags -static` flag: + +``` +v -prod -cc aarch64-linux-gnu-gcc -cflags -static -o hello . +``` + +Done. + +Since we were operating as the root user inside the container, it's worth +changing the file owner: + +``` +chown 1000:1000 hello +``` + +Replace `1000:1000` with your actual `UID:GID` pair on host system. + +Now we can exit from container: + +``` +exit +``` diff --git a/README.md b/README.md index 1eaa273..b1f039f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Produced binaries: * Linux: `amd64`, `arm64`, `arm32` (`armhf`), `ppc64le`, `s390x`, `riscv64` * Windows: `amd64` -* FreeBSD: `amd64` +* ~~FreeBSD: `amd64`~~ (disabled for now) The example programm is just `Hello World!`. For complex programs you may need to add more dependencies in build container. @@ -23,60 +23,30 @@ I relied on Debian's excellent cross-compilation support (see the Dockerfile), but with some elbow grease, you can compile the program for other architectures and operating systems. -Build: +**Build** + +Run: ``` -docker build . -t vlang-cross:latest-trixie +./make.vsh ``` +make.vsh script will build the Docker image and run crosscompile.vsh inside +a container. + The container image is large (almost 3GiB) due to the number of libraries required for cross-compilation. The size could actually be reduced, but that's what Debian provides by default in the `crossbuild-essential-*` packages. For the same reason, building the image isn't very fast (up to ~3 minutes for me). -Start cross-compilation: +You may need change `docker_command` in `make.vsh` to `sudo docker` if your +host user does not have access to Docker daemon. -``` -docker run --rm -v .:/app vlang-cross:latest-trixie env DEBUG=1 ./make.vsh -``` - -then look inside `release/` dir (: - -## Synopsis - -You can run the make.vsh script in two ways: - -``` -./make.vsh -# or -v run make.vsh -``` - -``` -Build script options: - -tasks List available tasks. - -help Print this help message and exit. Aliases: help, --help. - -Build can be configured throught environment variables: - - BUILD_PROG_NAME Name of the compiled program. By default the name is - parsed from v.mod. - BUILD_PROG_VERSION Version of the compiled program. By default the name - is parsed from v.mod. - BUILD_PROG_ENTRYPOINT The program entrypoint. Defaults to '.' (current dir). - BUILD_OUTPUT_DIR The directory where the build artifacts will be placed. - Defaults to './release'. - BUILD_SKIP_TARGETS List of build targets to skip. Expects comma-separated - list without whitespaces e.g. 'windows-amd64,linux-armhf' - BUILD_COMMON_VFLAGS The list of V flags is common for all targets. Expects - comma-separated list. Default is '-prod,-cross'. - BUILD_COMMON_CFLAGS Same as BUILD_COMMON_VFLAGS, but passed to underlying - C compiler. Default is '-static'. - DEBUG If set enables the verbose output as dimmed text. -``` +Look inside `release/` dir after compilation (: ## See Also +* [MANUAL.md](MANUAL.md) in this repository. * `v help build` * `v help build-c` * https://docs.vlang.io/cross-compilation.html diff --git a/crosscompile.vsh b/crosscompile.vsh new file mode 100755 index 0000000..2b77466 --- /dev/null +++ b/crosscompile.vsh @@ -0,0 +1,234 @@ +#!/usr/bin/env -S v run + +import arrays.parallel +import os +import os.cmdline +import term +import v.vmod + +/* + SETTING BUILD TARGETS + + All build targets must be defined in the build_targets const below. + Each target is a V struct. Fields are: + + name string + The target name. Prefer to use https://wiki.osdev.org/Target_Triplet + Note that target name will be used in output file name. For example + the 'linux-riscv64' becomes to 'myprog-1.2.3-linux-riscv64'. This is + very common naming scheme for compiled program distributions. + + cc string + C Compiler to use e.g. '/usr/bin/gcc', 'clang', etc. + + vflags []string + cflags []string + ldflags []string + Flags which will be passed to V compiler. See `v help build-c` for info. + + filename string + Output file naming pattern. By default is '%n-%v-%t'. + %n will be replaced with the program name (from v.mod by default) + %v will be replaced with the program version (also from v.mod) + %t will be replaced with the target name from `name` field. + For example this is useful for Windows builds: for target named + 'windows-amd64' and '%n-%v-%t.exe' filename pattern value you will get + artifact named 'progname-1.2.3-windows-amd64.exe'. + + See also Target struct definition below. + + V'S SPECIAL ENVIRONMENT VARIABLES + + VCROSS_COMPILER_NAME See vcross_compiler_name() in v.pref module. + VCROSS_LINKER_NAME See vcross_linker_name() in v.pref module. +*/ + +const build_targets = [ + Target{ + name: 'linux-amd64' + cc: 'gcc' + }, + Target{ + name: 'linux-arm64' + cc: 'aarch64-linux-gnu-gcc' + }, + Target{ + name: 'linux-armhf' + cc: 'arm-linux-gnueabihf-gcc' + }, + Target{ + name: 'linux-ppc64le' + cc: 'powerpc64le-linux-gnu-gcc' + }, + Target{ + name: 'linux-s390x' + cc: 's390x-linux-gnu-gcc' + }, + Target{ + name: 'linux-riscv64' + cc: 'riscv64-linux-gnu-gcc' + }, + Target{ + name: 'windows-amd64' + vflags: ['-os', 'windows'] + filename: '%n-%v-%t.exe' + }, + // FreeBSD build is buggy, disable it for now... + // Target{ + // name: 'freebsd-amd64' + // vflags: ['-os', 'freebsd'] + // }, +] + +struct Target { + name string + cc string + vflags []string + cflags []string + ldflags []string + filename string = '%n-%v-%t' +} + +fn (target Target) output_file() string { + // vfmt off + return target.filename.replace_each([ + '%n', build_config.program_name, + '%v', build_config.program_version, + '%t', target.name, + ]) + // vfmt on +} + +const build_config = BuildConfig.new() + +struct BuildConfig { + program_name string + program_version string + program_entrypoint string + output_dir string +} + +fn BuildConfig.new() BuildConfig { + manifest := vmod.decode(@VMOD_FILE) or { vmod.Manifest{} } + return BuildConfig{ + program_name: os.getenv_opt('BUILD_PROG_NAME') or { manifest.name } + program_version: os.getenv_opt('BUILD_PROG_VERSION') or { manifest.version } + program_entrypoint: os.getenv_opt('BUILD_PROG_ENTRYPOINT') or { '.' } + output_dir: os.abs_path(os.norm_path(os.getenv_opt('BUILD_OUTPUT_DIR') or { + 'release' + })) + } +} + +fn make_build(build_target Target) ! { + artifact := os.join_path_single(build_config.output_dir, build_target.output_file()) + + eprintln(term.bold('Building artifact: ${artifact}')) + + os.mkdir_all(os.dir(artifact)) or {} + + mut vargs := []string{} + if build_target.cc != '' { + vargs << ['-cc', build_target.cc] + } + for vflag in build_target.vflags { + vargs << vflag + } + for cflag in build_target.cflags { + vargs << ['-cflags', cflag] + } + for ldflag in build_target.ldflags { + vargs << ['-ldflags', ldflag] + } + vargs << ['-o', artifact] + vargs << build_config.program_entrypoint + + execute_command(@VEXE, vargs)! +} + +fn execute_command(executable string, args []string) ! { + path := os.find_abs_path_of_executable(executable) or { os.norm_path(executable) } + printdbg("Run '${path}' with arguments: ${args}") + mut proc := os.new_process(path) + proc.set_args(args) + proc.set_work_folder(os.getwd()) + proc.run() + proc.wait() + if proc.status == .exited && proc.code != 0 { + return error('Command ${term.bold(path)} exited with non-zero code ${proc.code}') + } +} + +fn printdbg(s string) { + if os.getenv('DEBUG') !in ['', '0', 'false', 'no'] { + eprintln(term.dim(s)) + } +} + +@[noreturn] +fn errexit(s string) { + eprintln(term.failed('Error: ${s}')) + exit(1) +} + +fn main() { + args := os.args[1..] + + mut targets := map[string]Target{} + for target in build_targets { + targets[target.name] = target + } + + options := cmdline.only_options(args) + if args.contains('help') || options.contains('-help') || options.contains('--help') { + println(help_text) + exit(0) + } + if options.contains('-targets') { + for name, _ in targets { + println(name) + } + exit(0) + } + if options.contains('-release') { + os.setenv('VFLAGS', '${os.getenv('VFLAGS')} -prod -cflags -static'.trim_space(), + true) + } + + printdbg('Args: ${args}') + printdbg('VFLAGS=${os.getenv('VFLAGS')}') + printdbg('VJOBS=${os.getenv('VJOBS')}') + printdbg(build_config.str()) + + mut to_build := []Target{} + for arg in cmdline.only_non_options(args) { + to_build << targets[arg] or { errexit("Invalid target: '${arg}', abotring...") } + } + if to_build.len == 0 { + to_build = targets.values() + } + + parallel.run(to_build, |build_target| make_build(build_target) or { errexit(err.msg()) }) +} + +const help_text = " + Build script options: + -targets List available targets. + -help Print this help message and exit. Aliases: help, --help. + -release Pass '-prod -cflags -static' flags to V. + + Build can be configured throught environment variables: + DEBUG If set enables the verbose output as dimmed text. + BUILD_PROG_NAME Name of the compiled program. By default the name is + parsed from v.mod. + BUILD_PROG_VERSION Version of the compiled program. By default version + is parsed from v.mod. + BUILD_PROG_ENTRYPOINT The program entrypoint. Defaults to '.' (current dir). + Specify file or module which have fn main() defined. + BUILD_OUTPUT_DIR The directory where the build artifacts will be placed. + Defaults to './release'. + + V-specific environment variables: + VFLAGS Set arbitrary flags for all jobs. + VJOBS Number of parallel jobs. Set it to enchanse compile speed. + ".trim_indent() diff --git a/make.vsh b/make.vsh index 9fd9045..99c3ef5 100755 --- a/make.vsh +++ b/make.vsh @@ -2,305 +2,66 @@ import build import crypto.sha256 -import maps import os import term -import v.vmod -const program_name = os.getenv_opt('BUILD_PROG_NAME') or { vmod_name() } -const program_version = os.getenv_opt('BUILD_PROG_VERSION') or { vmod_version() } -const program_entrypoint = os.getenv_opt('BUILD_PROG_ENTRYPOINT') or { '.' } -const output_dir = os.abs_path(os.norm_path(os.getenv_opt('BUILD_OUTPUT_DIR') or { 'release' })) -const skip_targets = os.getenv('BUILD_SKIP_TARGETS') -const common_vflags = os.getenv_opt('BUILD_COMMON_VFLAGS') or { '-prod' } -const common_cflags = os.getenv_opt('BUILD_COMMON_CFLAGS') or { '-static' } -const debug = os.getenv('DEBUG') -const vexe = @VEXE +const output_dir = './release' +const docker_command = 'docker' +const docker_image = 'vlang-cross:latest-trixie' +const compile_command = 'env VFLAGS="-cflags -s" DEBUG=1 ./crosscompile.vsh -release' -/* - SETTING BUILD TARGETS - - All build targets must be defined in the build_targets const below. - Each target is a V struct. Fields are: - - name string - The target name. Prefer to use https://wiki.osdev.org/Target_Triplet - Note that target name will be used in output file name. For example - the 'linux-riscv64' becomes to 'myprog-1.2.3-linux-riscv64'. This is - very common naming scheme for compiled program distributions. - - cc string - C Compiler to use e.g. '/usr/bin/gcc', 'clang', etc. - - vflags []string - cflags []string - ldflags []string - Flags which will be passed to V compiler. See `v help build-c` for info. - - file_ext string - Extension for produced binary file. Useful for Windows builds. file_ext - is concatenated to filename. For example for target named 'windows-amd64' - and '.exe' file_ext you will get 'progname-1.2.3-windows-amd64.exe'. - - disabled bool - If true target will be disabled. Target building will be skipped. Also - target will not provided in tasks list in `./make.vsh -tasks` output. - - common_vflags bool - If true, set additional flags listed in BUILD_COMMON_VFLAGS. - See `./make.vsh -help` for info or read help_text const below. Is true - by default. - - common_cflags bool - The same as common_vflags, but for C compiler. Environment variable is - BUILD_COMMON_CFLAGS. Is true by default. - - calculate_sha256 bool - If true, calculate SHA256 hashsum of produced binary and create new - artifact with the same name but with '.sha256' extension. File content - is the same as Linux `sha256sum` utility output. Is true by default. - - See also Target sttruct definition in bottom of this file. - - V'S SPECIAL ENVIRONMENT VARIABLES - - VCROSS_COMPILER_NAME See vcross_compiler_name() in v.pref module. - VCROSS_LINKER_NAME See vcross_linker_name() in v.pref module. -*/ - -const build_targets = [ - Target{ - name: 'linux-amd64' - cc: 'gcc' - }, - Target{ - name: 'linux-arm64' - cc: 'aarch64-linux-gnu-gcc' - }, - Target{ - name: 'linux-armhf' - cc: 'arm-linux-gnueabihf-gcc' - }, - Target{ - name: 'linux-ppc64le' - cc: 'powerpc64le-linux-gnu-gcc' - }, - Target{ - name: 'linux-s390x' - cc: 's390x-linux-gnu-gcc' - }, - Target{ - name: 'linux-riscv64' - cc: 'riscv64-linux-gnu-gcc' - }, - Target{ - name: 'windows-amd64' - cc: 'x86_64-w64-mingw32-gcc' - vflags: ['-os', 'windows'] - file_ext: '.exe' - }, - Target{ - // FreeBSD build for now is dynamically linked even if -cflags -static is passed. - // Also V forces the use of clang here (unless VCROSS_COMPILER_NAME envvar is set), - // so -cc value doesn't matter. - name: 'freebsd-amd64' - cc: 'clang' - vflags: ['-os', 'freebsd'] - }, -] - -const help_text = " - Build script options: +const help_text = ' + Options: -tasks List available tasks. -help Print this help message and exit. Aliases: help, --help. + '.trim_indent() - Build can be configured throught environment variables: - - BUILD_PROG_NAME Name of the compiled program. By default the name is - parsed from v.mod. - BUILD_PROG_VERSION Version of the compiled program. By default the name - is parsed from v.mod. - BUILD_PROG_ENTRYPOINT The program entrypoint. Defaults to '.' (current dir). - BUILD_OUTPUT_DIR The directory where the build artifacts will be placed. - Defaults to './release'. - BUILD_SKIP_TARGETS List of build targets to skip. Expects comma-separated - list without whitespaces e.g. 'windows-amd64,linux-armhf' - BUILD_COMMON_VFLAGS The list of V flags is common for all targets. Expects - comma-separated list. Default is '-prod,-cross'. - BUILD_COMMON_CFLAGS Same as BUILD_COMMON_VFLAGS, but passed to underlying - C compiler. Default is '-static'. - DEBUG If set enables the verbose output as dimmed text. - ".trim_indent() - -fn main() { - if 'help' in os.args || '-help' in os.args || '--help' in os.args { - println(help_text) - exit(0) - } - mut context := build.context(default: 'all') - mut targets := []string{} - for build_target in build_targets { - targets << build_target.name - context.task( - name: build_target.name - help: 'Make release build for ${build_target.name} target' - run: fn [build_target] (t build.Task) ! { - make_build(build_target)! - } - should_run: fn [build_target] (t build.Task) !bool { - return is_command_present(build_target.cc)! - && build_target.name !in skip_targets.split(',') - } - ) - } - context.task( - name: 'all' - help: 'Make release builds for all target systems' - depends: targets - run: |self| true - ) - context.task( - name: 'clean' - help: 'Cleanup the output dir (${output_dir})' - run: |self| cleanup()! - ) - context.run() +if 'help' in os.args || '-help' in os.args || '--help' in os.args { + println(help_text) + exit(0) } -fn make_build(build_target Target) ! { - printdbg('Env BUILD_PROG_NAME = ${program_name}') - printdbg('Env BUILD_PROG_VERSION = ${program_version}') - printdbg('Env BUILD_PROG_ENTRYPOINT = ${program_entrypoint}') - printdbg('Env BUILD_OUTPUT_DIR = ${output_dir}') - printdbg('Env BUILD_SKIP_TARGETS = ${skip_targets.split(',')}') - printdbg('Env BUILD_COMMON_VFLAGS = ${common_vflags}') - printdbg('Env BUILD_COMMON_CFLAGS = ${common_cflags}') +mut context := build.context(default: 'release') - os.mkdir(output_dir) or {} +context.task( + name: docker_image + help: 'Build Docker image for cross-compilation' + run: |self| os.system('${docker_command} build -t ${docker_image} .') +) - artifact := os.join_path_single(output_dir, program_name + '-' + program_version + '-' + - build_target.name + build_target.file_ext) - printdbg('Building artifact: ${artifact}') +context.task( + name: 'build' + help: 'Build binaries' + run: |self| os.system('${docker_command} run --rm -v .:/app ${docker_image} ${compile_command}') + should_run: |self| os.is_dir_empty(output_dir) + depends: [docker_image] +) - mut vargs := []string{} - if build_target.common_vflags { - for vflag in common_vflags.split(',') { - if vflag != '' { - vargs << vflag - } - } - } - for vflag in build_target.vflags { - vargs << vflag - } - vargs << ['-cc', build_target.cc] - if build_target.common_cflags { - for cflag in common_cflags.split(',') { - if cflag != '' { - vargs << ['-cflags', cflag] - } - } - } - for cflag in build_target.cflags { - vargs << ['-cflags', cflag] - } - for ldflag in build_target.ldflags { - vargs << ['-ldflags', ldflag] - } - vargs << ['-o', artifact] - vargs << program_entrypoint +context.task( + name: 'sha256sums' + help: 'Calculate SHA256 sums for built binaries' + run: |self| os.walk(output_dir, fn (file string) { + out_file := os.abs_path(file + '.sha256') + eprintln(term.bold('Generating: ${out_file}')) + data := os.read_bytes(file) or { return } + sum := sha256.sum(data) + result := '${sum.hex()} ${os.file_name(file)}\n' + os.write_file(out_file, result) or { return } + }) + depends: ['build'] +) - execute_command(vexe, vargs, env: build_target.env)! +context.task( + name: 'release' + help: 'Make release' + run: |self| true + depends: ['sha256sums'] +) - if build_target.calculate_sha256 { - sha256sum_file := artifact + '.sha256' - printdbg('Generating SHA256 sum: ${sha256sum_file}') - file_bytes := os.read_bytes(artifact)! - sum := sha256.sum(file_bytes) - result := '${sum.hex()} ${os.file_name(artifact)}\n' - printdbg('Calculated SHA256: ${result}') - os.write_file(sha256sum_file, result)! - } -} +context.task( + name: 'clean' + help: 'Cleanup build directory (delete all build artifacts)' + run: |self| os.rmdir_all(output_dir) or {} +) -fn cleanup() ! { - printdbg('Try to delete ${output_dir} recursively...') - os.rmdir_all(output_dir) or { - if err.code() == 2 { - printdbg('${output_dir} does not exists') - } else { - return err - } - } - printdbg('Cleanup done') -} - -// Helper functions - -fn vmod_name() string { - if manifest := vmod.decode(@VMOD_FILE) { - return manifest.name - } - return 'NAMEPLACEHOLDER' -} - -fn vmod_version() string { - if manifest := vmod.decode(@VMOD_FILE) { - return manifest.version - } - return 'VERSIONPLACEHOLDER' -} - -fn is_command_present(cmd string) !bool { - if os.exists_in_system_path(cmd) { - return true - } - if os.is_executable(os.abs_path(os.norm_path(cmd))) { - return true - } - printwarn('Command ${term.bold(cmd)} is not found') - return false -} - -@[params] -struct CommandOptions { - env map[string]string -} - -fn execute_command(executable string, args []string, opts CommandOptions) ! { - path := os.find_abs_path_of_executable(executable) or { os.norm_path(executable) } - printdbg("Run '${path}' with arguments: ${args}") - mut proc := os.new_process(path) - proc.set_args(args) - proc.set_environment(maps.merge(os.environ(), opts.env)) - proc.set_work_folder(os.getwd()) - proc.run() - proc.wait() - if proc.status == .exited && proc.code != 0 { - return error('Command ${term.bold(path)} exited with non-zero code ${proc.code}') - } -} - -fn printdbg(s string) { - if debug !in ['', '0', 'false', 'no'] { - eprintln(term.dim(s)) - } -} - -fn printwarn(s string) { - eprintln(term.bright_yellow(s)) -} - -struct Target { - name string - cc string - vflags []string - cflags []string - ldflags []string - file_ext string - env map[string]string - - common_vflags bool = true - common_cflags bool = true - calculate_sha256 bool = true -} +context.run()