Ray-D-Song's Blog

C 与 Go 的编译期控制

2026-3-5 5min

编写软件时一个常见的需求,就是 Debug 版本打印更多调试信息,而正式版不包含。

实现这个效果有两种做法,一种是运行时控制,通过环境变量,配置文件来决定是否要调用调试函数。

另一种是在编译时就根据编译的条件来生成两份代码,这种叫做编译时控制

编译时控制的好处是,不需要的代码一开始就不会出现在可执行文件中,产物体积更小,性能更好,除非运行时需要动态打开开关,不然大多数时候是最优解。

C 语言的编译期控制

C 语言的编译期控制主要依赖编译宏。

// 定义一个名为 DEBUG 宏,值为 1
#define DEBUG 1

// 如果 DEBUG 宏被定义(无论值是多少),则编译下面代码
#ifdef DEBUG
printf("value=%d\n", x);
#endif

可以看到,通过编译宏,C 语言可以做到代码块级别的编译控制。

一般 C 项目还会搭配 Makefile 和 CMake,使用这类构建系统时,就不需要在代码中硬编码宏 define。

Makefile 示例:

CC = gcc
CFLAGS = -Wall

TARGET = app
SRC = main.c

# build 命令的产物会包含 Debug 代码
build:
	$(CC) $(CFLAGS) -DDEBUG $(SRC) -o $(TARGET)        # 这里的 -DDEBUG 等价于 #define DEBUG 1,这时代码中就不需要再写 `#define DEBUG 1`

# buildprod 命令的产物不包含 Debug 代码
buildprod:
	$(CC) $(CFLAGS) $(SRC) -o $(TARGET)

CMakeLists 示例:

cmake_minimum_required(VERSION 3.16)
project(app C)

add_executable(app main.c)

# Debug 构建时定义 DEBUG(等价于 -DDEBUG 或 -DDEBUG=1)
target_compile_definitions(app PRIVATE $<$<CONFIG:Debug>:DEBUG=1>)

构建时:

# Debug(有 DEBUG)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

# Release(无 DEBUG)
cmake -S . -B buildprod -DCMAKE_BUILD_TYPE=Release
cmake --build buildprod

Go 语言的编译期控制

Go 语言的编译期控制主要通过 Build Tag(也称为 构建约束,Build Constraints)来实现。

最常见的使用场景是:针对不同的操作系统平台提供不同的实现代码。在这种情况下,通常会为同一功能创建多个平台对应的源码文件,例如:

file_linux.go
file_windows.go

Go 在编译时会根据目标平台,自动选择符合条件的文件进行编译。

//go:build linux

//该文件仅在构建目标为 Linux 平台时才会被编译。如果编译目标是 Windows 或其他平台,这个文件会被自动忽略。
package main

func platform() string {
    return "Linux"
}

可以看到,Go 的 build tag 只能控制“文件是否参与编译” ,而 C 的 #ifdef 可以控制代码块, Go 的控制粒度要粗的多。

所以 Go不能像 C 那样在函数内部写条件编译,但可以通过 文件拆分 + build tag 达到类似效果。

//go:build debug

package main

func DebugPrint(x int) {
    fmt.Printf("value=%d\n", x)
}
//go:build !debug

package main

func DebugPrint(x int) {} //这里只有空实现
go build -tags debug

在第二个文件中,我们只留了一个 stub implementation(空实现),空实现本身不会被排除编译,但通常可以做到接近 0 运行时开销。是否完全 0 开销取决于代码是否被 内联(inline)和死代码消除(dead code elimination)。

如果编译器没有 inline 调用代码会变成:

CALL Debug

Debug 里面什么都不做然后 return,开销是一次函数调用和一次 return,很小但不是 0。

不过 Go 编译器通常会自动 inline 很小的函数,这时配合死代码消除会让函数调用直接消失,最终生成的汇编完全没有这部分代码。

为了检测函数是否被内联,可以用以下编译命令:

go build -gcflags\="-m"

# 如果输出
# inlining call to Debug
# 说明编译器把调用内联了。