炸毛的猫炸毛的猫
归档
java
框架
中间件
数据库
杂谈
影墙
归档
java
框架
中间件
数据库
杂谈
影墙
  • 项目为什么要混合多种语言?一次编译、解释、JIT、AOT的探索之旅

项目为什么要混合多种语言?一次编译、解释、JIT、AOT的探索之旅

前言

最近深感学习不足,却缺乏动力,于是另辟蹊径,决定进行一些探索性学习 -- 先确定目标,再细化研究,也算是以兴趣驱动了。

作为后端开发,在语言层次之上,深感各种框架层出不穷,它们是设计与实现上的堆叠,甚至会有很多相似设计。

而对于语言层次之下,编译后的代码到底是如何被 OS 进行执行,JVM又是如何执行字节码代码块,接近低层的概念,我几乎一无所知。

在此之上,我决定了文章的标题,本文将预计将按照以下大部分学习讲解(笔者大概会一边查资料、一边实践代码、一边完成此篇文章),探索路线如下:

  1. 编译型语言混合
  2. 解释型语言混合
  3. 解释型语言原理:实现一个简易解释器
  4. JIT与AOT:JIT主要应用与解释型语言的动态优化技术,而AOT是静态优化技术,Java Native Image 即通过AOT将字节码直接编译为了可执行程序。
  5. 统一 IR 的运行时生态: WASM,JVM):不同语言编译为同一个中间表示,再在统一的运行时里执行。

灵感及部分插图来源:

【为什么有些项目会使用多种编程语言?】https://www.bilibili.com/video/BV1aXbXzfEsM?vd_source=5c84368f731f642fd424bf08477f0db7

【Why Some Projects Use Multiple Programming Languages】https://www.youtube.com/watch?v=XJC5WB2Bwrc

以上视频讲解了一个项目是如何混合多语言进行编程,对于细节和实现进行了介绍

【深入理解JVM - 虚拟机执行子系统(类结构章节)】

讲解了类的基本结构,由此我了解字节码指令在方法表中是如何存储,字节码执行引擎具体实现我没有去了解,不过我准备在AI指导下快速实现一个简易功能的执行引擎。

如何理解语言混合

如何去理解混合语言呢?

最简单的一个例子:前后端分离开发

image-20250902234536441

在这里 Python 基于Web框架,提供了HTTP接口;HTML + JavaScript + CSS 构成了一个前段页面,并通过HTTP接口来获取渲染页面数据。

在这里,实际存在了两个进程,它们之间通过进程间通信,来实现数据交互。

这种的并不难理解,但是我们接下来要说的,分析的是另一种形式:使用不同语言编写的项目组件,作为单一进程共同运行。

image-20250902235838192

编译型语言混合

我们先来确认下对于这个问题的探究提纲吧~

  • 示例:C + Rust + Go + 汇编 的混合编译

  • 多个编译型语言如何一起协作?各个语言的运行时管理机制如何处理?

  • 静态编译 + 链接的原理分析与实践

编译的步骤:C语言如何处理 预编译 -> 汇编 -> 编译 -> 链接(静态 + 动态(依赖变更,其他编译后的程序不需要改动)(动态链接的原理?装载内存后,不同应用是否公用?这个原理是什么?是否与JIT相关?))

如何将某个语言构建为依赖库,在另一个语言动态链接

语言混合开发,如何遵守ABI规范? 基于各个语言相关的外部函数关键字吗?

原理:从源代码到可执行程序

编译流程:以C语言为例

  1. Pre-Processor 预处理:注释删除,展开宏定义(解析#include,将目标头文件代码插入到文件中),处理条件编译(#ifdef等)等操作

    生成结果:被处理过后的C代码,可读.

  2. Compiler 编译:将处理过后的代码转译为汇编语言

    生成结果:汇编语言,可读。

  3. Assembler 汇编:将汇编代码翻译为机器码(01指令)

    生成结果:目标文件,但仍无法运行,需要确认二进制文件中的具体位置及其各函数的位置。

  4. Linker 链接:在此阶段,我们可能会存在多个目标文件,部分来自项目的代码,其他则来自开发时引入的外部库。

    // stdio.h 是C语言标准库头文件,他的实现代码也需要被处理为目标文件 stdio.o
    #include<stdio.h>
    int main() {
        printf("Hello World");
        return 0;
    }

    将所有目标文件整合起来,即可得到最终的独立可执行文件。

    生成结果:独立可执行的文件。

    实现方式

    • 静态链接:从库中提取每个所需函数的机器码,将其复制到最终的可执行文件。

    • 动态链接:将函数库预编译为 动态共享库(UNIX中.so文件,Windows中.dll文件),它们都包含函数库所提供的可执行函数代码,但不包含启动执行的入口点。在此种编译情况下,连接器会在最终只插入对该库引用,而库存储了该函数的机器指令。

      在运行时,进行加载动态共享库,在程序中动态计算真实内存地址(这里我比较好奇,DeepSeek告知这种动态计算逻辑是链接器添加到了可执行文件的同步,在程序启动时,发生一次计算),不同进行可以共用内存中的同一个共享库文件代码。

可以通过命令将GCC执行各个步骤文件显示

gcc_test % gcc main.c -o main -save-temps
gcc_test % ls
main	main.bc	main.c	main.i	main.o	main.s
# main.c 源文件
# Pre-Processor -> main.i 预处理后文件
# Complier -> main.s 汇编文件
# Assembler -> main.o 机器码
# Linker -> main 可执行文件
# main.bc 是GCC在编译过程中生成的LLVM字节码文件(Bitcode), 类似于汇编的一种中间表示文件, 目标用于链接优化或跨平台编译, 我在MacOS下使用clang产生, 可以忽略

编译阶段化意味着什么?

从上文我们可以得知,编译事实上是由很多不同的阶段组成的。

通过GCC指令我们可以让其在某个阶段进行停止,例如下方示例,在生成汇编代码后停止。在某些场景下,可以进行审查关键代码的性能。

gcc_test % gcc main.c -S
gcc_test % ls
main.c	main.s

我们也可以直接将汇编代码交给GCC去构建可执行文件

gcc_test % gcc main.s -o main
gcc_test % ls
main	main.c	main.s

这意味着我们可以直接编写一个汇编代码,在不同阶段来最终交给编译器进行处理,最终交由链接器去处理为一个可执行文件。

image-20250914220407289

在这个过程中,我们实际上已经混合了两种不同的语言:汇编语言、C语言,例如大名鼎鼎的 FFmpeg,今年我还有看到文章,某个贡献者直接编写汇编代码将某个函数性能带来了巨量的提升。

由此我们也可以窥探到,其实不同编译型语言的混合,也是基于此,它们在编写后,最终只需要通过编译器处理链接在一起即可,而一个进程的不同模块、功能的实现,可以有更多的语言选择,开发者可以基于相关语言的生态或者性能进行择优选择。

什么是GCC

GCC全称为 GNU Compiler Collection,是一组按顺序执行的工具执行链,每个工具都接受上一个工具的输出作为输入,并产生自己的输出,且工具都是可插拔的, 我们可以对其进行替换,或者在中间阶段注入我们自己的文件。

image-20250914223732650

基于配置项,它可支持很多种语言

The GNU Compiler Collection includes front ends for C, C++, Objective-C, Fortran, Ada, Go, D, Modula-2, and COBOL as well as libraries for these languages (libstdc++,...).

GNU 也是一段故事,其全称为 GNU's Not Unix,其全称是个可递归的名字,这个项目开发了很多关键组件,如 GCC,是因当时闭源商业化的UNIX系统而引发的项目。

高级语言混合开发

在上面的描述中,我们看到了编译的不同阶段,我们通过控制不同阶段的编译器功能执行,甚至可以做到汇编语言和C语言程序的混合编译。

汇编语言终归是编译步骤中的一环,更多是基于生态与特性去选取高级语言进行混合开发。

image-20250914234044614image-20250914234021656

其关键点实际在于最终 链接 步骤,Linker 是实现的关键。

不同语言混合开发的连接点事实上是目标文件(.o),此时文件内已经为机器码和符号表。

此时再用同一个 Linker 链接成为同一个二进制文件即可。

混合语言的关键:ABI

不同语言编译器在编译阶段对一些底层细节的处理方式是不同的,比如函数调用约定、符号命名规则、内存对齐方式、返回值处理方式等实现差异。这些差异会直接影响目标文件中机器码与符号表的生成方式。那么不同语言目标文件链接在一起,会导致其代码执行存在偏差异常。ABI 是解决此问题的答案。

什么是 ABI Application Binary Interface,应用二进制接口。它在多语言混合开发中非常关键,它保证了不同编译器(甚至不同语言)生成的代码能够通过同一个 Linker 进行链接。

ABI 规范了以下核心内容:

  • 函数调用约定:参数传递(寄存器传递、栈传递);返回值处理;栈清理
  • 符号命名约定:不同编译器与语言采用不同的符号命名规则
  • 数据结构内存布局:不同语言对结构体、类、枚举的内存对齐、布局可能不同,需要确保内存布局一致
  • 系统调用和标准库接口:保证系统调用或标准库函数在不同语言中的一致性,确保程序在不同语言间交互时不出问题。

Linker 不关心源代码是由哪种语言编写,它只关心目标文件中已经处理好的机器码和符号。

目标文件中内部包含了编译器生成的机器码与符号表,这些符号信息与实际机器码通过ABI进行对接,保证了不同语言间的无缝链接。

其实我很纠结应该到哪种细致程度,关于 ABI 机制,我很想了解到 Linker 到底是如何基于 ABI 进行链接,什么原理,如何检测到 ABI 是否符合。

但是从专业领域和文章目的来讲,深究有点超出我目前的能力与文章的目的了,以上内容也是基于 ChatGPT 创作,感兴趣大家自行多多了解吧。

实验:多种编译型语言开发

环境搭建

采用 Dockerfile 直接构建一个 Rust + C + 汇编的开发编译环境

FROM rust:1.81

# 替换为阿里云 Debian 源
RUN rm -f /etc/apt/sources.list.d/debian.sources && \
    echo "deb http://mirrors.aliyun.com/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \
    apt update && apt install -y build-essential nasm
gcc -c cfunc.c -o cfunc.o

WORKDIR /app
COPY . /app
CMD ["bash"]

打包

docker build -t rust-mix .

执行

# 建议先把下方源码环境搭建,再通过挂在方式将目标文件映射进行,因为当前镜像没安装vi/vim
docker run -it --rm -v ./mix_demo:/app/mix_demo rust-mix

源码

构建一个 Rust 项目,通过 Cargo Build.rs 来将 C / ASM 编译为目标文件,再通过 rustc 链接 Rust 标准库后生成可执行文件。

cargo new mix_demo

文件目录

mix_demo
├── build.rs
├── Cargo.lock
├── Cargo.toml
└── src
    ├── asmfunc.s
    ├── cfunc.c
    └── main.rs
2 directories, 6 files

build.rs

fn main() {
    cc::Build::new()
        .file("src/cfunc.c")
        .file("src/asmfunc.s")
        .compile("mix_lib");
}

Cargo.toml

[package]
name = "mix_demo"
version = "0.1.0"
edition = "2021"

[build-dependencies]
cc = "1.0"

main.rs

extern "C" {
    fn c_add(a: i32, b: i32) -> i32;
    fn asm_mul(a: i64, b: i64) -> i64;
}

fn main() {
    let a = 6i32;
    let b = 7i32;

    unsafe {
        let sum = c_add(a, b);
        let product = asm_mul(a as i64, b as i64);
        println!("C result: {}", sum);
        println!("ASM result: {}", product);
    }
}

cfunc.c

int c_add(int a, int b) {
    return a + b;
}

asmfunc.s

.global asm_mul
asm_mul:
    mul x0, x0, x1
    ret

执行结果

root@xxxxxx:/app/mix_demo# cargo run
   Compiling shlex v1.3.0
   Compiling find-msvc-tools v0.1.4
   Compiling cc v1.2.41
   Compiling mix_demo v0.1.0 (/app/mix_demo)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.96s
     Running `target/debug/mix_demo`
C result: 13
ASM result: 42

总结

经过上方的过程,我们了解了语言编译的过程,以及项目是如何进行混合开发,混合编译到同一个执行程序之中。

其实还有很多细节没有深挖,如:

  • 混合编译的情况下,像是 Go 这种自带垃圾回收机制的语言,其程序是如何处理的呢?
  • Linker 在链接过程之中,到底检查了什么,如何检查并链接的呢?
  • ABI 机制如何在目标文件中体现出来

不过就到此为止吧。

解释型语言的混合

解释型语言混合的示例就很好做啦,我们来试一下 Java + Kotlin 吧。

JVM 是以字节码为基础进行执行的,混合开发其实不难理解,无论上层是什么,最终只要符合字节码规范即可。

而且不同语言垃圾回收机制是在 JVM 层进行保证,不涉及不同语言的运行时问题。

源码

文件目录

~/mix_demo
├── build
│   └── reports
│       └── problems
│           └── problems-report.html
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── java
    │   │   ├── KotlinFunc.kt
    │   │   └── Main.java
    │   └── resources
    └── test
        ├── java
        └── resources

13 directories, 9 files

build.gradle.kts

plugins {
    kotlin("jvm") version "1.9.0" // Kotlin 插件,确保使用的是最新版本
    application
}

repositories {
    mavenCentral() // 使用 Maven 中央仓库
}

dependencies {
    implementation(kotlin("stdlib")) // Kotlin 标准库
}

kotlin {
    jvmToolchain(8)
}

application {
    mainClass.set("Main") // Kotlin 入口类
}

Main

/**
 * Main
 * description: Main
 * date 2025/10/16
 * @author yancy0109
 */
public class Main {
    public static void main(String[] args) {
        System.out.println("Hello World! From Java");
        System.out.println(new KotlinFunc().greet("Java"));
    }
}

KotlinFunc

/**
 * KotlinFunc
 * description: KotlinFunc
 * date 2025/10/16
 * @author yancy0109
 */
class KotlinFunc {
    fun greet(name: String): String {
        return "Hello String from Kotlin, Invoker: $name"
    }
}

执行结果

./gradlew run
> Task :run
Hello World! From Java
Hello String from Kotlin, Invoker: Java
.......

解释型语言原理:实现一个解释器

你是否好奇过,解释型语言是如何实现的?例如,JVM是如何通过字节码构建一个与平台无关的执行环境?在《深入理解JVM》中,我们可以了解到,JVM本质上是一个虚拟机,它使用字节码指令来执行程序,而这些字节码指令的结构和机器码指令有些相似,但它们并不直接与硬件交互,而是通过JVM进行执行。这种机制让JVM在不同平台上具有高度的可移植性。JVM的字节码并不总是完全解释执行,随着JIT(即时编译)的加入,JVM可以将字节码在运行时编译为机器码,从而提升性能。

我非常好奇如何通过实现一个简单的解释器来理解这一过程,让我们动手实现一个简单的解释器来理解这一个过程吧。目标包括:

  • 实现一个类字节码的执行引擎
  • 面向对象
  • 基于引用计数的垃圾回收机制
  • 支持简单语法:单层 IF-ELSE,FOR循环

过渡问题:那么,为什么有时解释器会比预编译的代码执行得更快呢?(这就引出了JIT技术,它如何帮助解释型语言突破性能瓶颈。)

动态优化技术(JIT & AOT)

解释器如何在运行时生成机器码

  • JIT 的作用:热点优化、内联缓存
  • AOT 的对比:预编译、启动快、优化受限
  • 示例:JVM、.NET、PyPy 的 JIT vs GraalVM AOT

统一 IR 的运行时生态

  • JVM、.NET CLR:多语言 → 字节码 → 同一运行时
  • WASM:多语言 → WASM IR → 浏览器 / 独立运行时
  • 总结:为什么我们能把 Python、Java、Scala 混合?答案就是 统一 IR 提供的共享运行时

​

Last Updated:
Contributors: yancy0109