Skip to content

背景

做开发这几年,版本号这件事其实一直在"用",但很少系统地"学过":

  • 主版本、次版本、修订号——大家都这么说,我也跟着说
  • 偶尔看到 1.2.3-rc.22.0.0-alpha.1 这种带后缀的,没深究过它们的差别
  • 别人项目里的 ^1.2.3~1.2.3 一直半懂不懂

本文系统整理一下软件版本号的"约定"。只讲抽象的"约定",不绑定任何具体平台或包管理器 🎯

🤔 为什么软件要有版本号

在软件开发里,版本号从来不只是"一个数字",它同时承担几种角色:

  • 唯一标识:让用户能精确定义"我用的是 3.1.2,不是 3.1.0"
  • 变更溯源:让维护者一眼读出"两次发布之间发生了什么级别的变化"
  • 兼容性契约:依赖方据此判断"升级是否会破坏我的代码"
  • 生命周期标记:配合 LTS、EOL 等概念,描述一款软件的支持窗口

如果一个软件没有版本号,它就变成了一个"持续在变"的东西——既无法交流,也无法管理。

🧩 版本号的基本组成部分

一个看似简单的版本号,其实是若干独立维度的组合。不同规范里这些段可能被省略或重命名,但概念本身基本是通用的。

主版本号(Major)

  • 位置:最左侧第一段,如 3.x.x 中的 3
  • 含义:表示软件的"代际"。一般意味着重大架构变更或不向后兼容的 API/行为变更
  • 典型触发:删除旧 API、改变核心架构、改动文件格式、改变用户必须重新学习的操作流程
  • 行业惯例:1.0.0 通常被视为"正式版起点";0.y.z 则表示"还在快速迭代,API 尚未稳定"

次版本号(Minor)

  • 位置:第二段,如 3.2.x 中的 2
  • 含义:在主版本号不变的前提下,新增功能或对现有功能做较大改进,通常保证向后兼容
  • 典型触发:增加新接口、新增可选配置项、加入新模块
  • 通常触发 Minor 递增的:纯 bug 修复、性能优化(这些通常进入 Patch)

修订号 / 补丁号(Patch)

  • 位置:第三段,如 3.2.1 中的 1
  • 含义:通常只用于 bug 修复或安全性修复,完全向后兼容
  • 典型触发:修边界条件下的崩溃、修界面错位、修文档错误
  • 严格按 SemVer 走的项目会要求 Patch 内不能包含功能新增,只做修复

构建号(Build Number)

  • 位置:通常作为额外段,或与日期绑定,例如 3.2.1.45673.2.1+20240721
  • 含义:区分同一次"发布"下的多次编译。一般对最终用户不可见,但对工程团队非常重要
  • 典型用途:
    • 内部 CI 出包时,每次流水线跑出来都自带一个递增号,便于回溯"这个包是哪次流水线打的"
    • 移动端打包场景(Android versionCode / iOS CFBundleVersion)里,应用商店强制要求构建号单调递增
    • 与 Git commit hash 配合,例如 1.2.3+g4f8a2b1

预发布标识(Pre-release)

常见的小写后缀或标签,用来表示"还没到正式版":

标识含义
alpha内部测试版,功能不完整,只给开发团队或极少数测试者
beta公开测试版,功能基本完整,但可能仍有 bug
rc(Release Candidate)候选发布版,若无重大问题就会成为正式版
preview / dev / nightly预览版 / 开发版 / 每夜构建版,频繁更新
snapshotMaven 生态中专有名词,指"仓库里最新一次构建"

例子:

  • 2.0.0-alpha.1:第二个大版本的第一个 alpha
  • 1.4.0-rc.2:1.4 的第二个候选版
  • v3.2.0-beta.3:第三个版本的第三个 beta("v" 前缀在 Git 标签里非常常见)

构建元数据(Build Metadata)

  • 位置:加号 + 之后,例如 1.0.0+20130313144700
  • 含义:不视为版本号的一部分,不参与"哪个版本更高"的比较
  • 典型内容:构建时间戳、编译器版本、Git hash、构建环境标识
  • SemVer 明确规定:1.0.0+exp.sha.5114f851.0.0+21AF26D3----117B344092BD 在版本比较时是相等

提交哈希(Commit Hash)

  • 一般作为"补充信息"出现,例如:
    • 短哈希:v1.2.3-abc1234
    • 长哈希:v1.2.3-abc1234567890abcdef1234567890abcdef12345
  • 用途:当版本号相同时,用 hash 区分"我装的是哪一次提交打出来的包"
  • 一些项目甚至完全不用数字版本号,只用日期 + 短 hash,例如 2024.07.21-g4f8a2b

📐 主流的版本号规范流派

业界并不存在"唯一正确"的版本号写法。下面几种流派各有适用场景,了解它们能让你在选型时心里有数。

1. 语义化版本(Semantic Versioning,SemVer)

  • 现行规范:semver.org 上的 SemVer 2.0.0
  • 格式:MAJOR.MINOR.PATCH[-预发布][+元数据]
  • 核心规则:
    1. 用了不兼容的 API 变更 → 递增 Major
    2. 新增了向后兼容的功能 → 递增 Minor、Patch 清零
    3. 做了向后兼容的修复 → 递增 Patch
  • 0.y.z 的特殊约定:当 Major 为 0 时(0.x.y),表示"还在快速开发,任何东西都可能随时破坏,不要把它当成稳定 API"
  • 1.0.0 的含义:"对外发布的第一个稳定版本"
  • 现状:Node.js 的 npm、Rust 的 Cargo、Go 的 go mod 等都按 SemVer 或其变体工作

2. 日历版本(Calendar Versioning,CalVer)

  • 现行规范:calver.org
  • 核心思想:把发布日期直接写进版本号
  • 常见格式:
    • YY.MM22.0424.10(Ubuntu 用得最广)
    • YYYY.MM.DD2024.07.21
    • YY.MM.PATCH24.07.3
  • 适用场景:
    • 操作系统、发行版(强时间节奏)
    • 强依赖"最新即最好"的 SaaS 类工具
    • 维护成本极低、变动频繁的工具
  • 优点:一眼看出"这东西多久没更新了";不需要维护者决定"这次够不够 Minor"
  • 缺点:不传达兼容性信号——用户很难从 24.0724.08 推出"会不会破坏我的脚本"

3. 零版本起步(0ver)

  • 思想:在软件进入"稳定"之前永不递增 Major,永远停在 0.x.y
  • 典型代表:Linux 内核长期停留在 0.x 时代(直到 1994 年的 1.0.0),如今一些个人项目、小工具也会无限期停在 0.x
  • 优点:简单,传达"我现在还没承诺稳定"
  • 缺点:随着 0.999.999 这种数字膨胀会显得滑稽——这也是为什么"1.0.0 焦虑症"(永远不敢发 1.0)经常被吐槽

4. 代号 / 主题名

  • 不使用数字,而用一个有意义的名字标识每个主要版本
  • 典型代表:
    • 苹果 macOS 历史上的猫科动物(Cheetah、Puma、Jaguar...)和加州地名(Mavericks、Catalina、Big Sur、Ventura、Sonoma、Sequoia...)
    • Android 的甜点代号(Cupcake、Donut、Eclair...,现在虽然代号还在,但对外宣传已弱化)
    • Ubuntu 的动物代号(Bionic Beaver、Focal Fossa、Jammy Jellyfish...)
  • 通常会配合一个基础版本号:Ubuntu 22.04 LTS (Jammy Jellyfish)

🔗 版本号与依赖管理

版本号不只是"自己发布的版本",还是别人引用你时的合同——这是大多数新手最容易踩坑的地方。

版本范围写法

依赖管理工具里,版本号常常和范围运算符一起出现。常见符号(不同生态大同小异):

写法含义
1.2.3锁死:只用 1.2.3 这一版
^1.2.3(npm)兼容 1.x.x 中 ≥ 1.2.3 的最新版
~1.2.3(npm)仅小版本内升级:1.2.x 中 ≥ 1.2.3 的最新版
>=1.2.3 <2.0.0区间写法
1.2.*通配符
*任意版本(极不推荐生产用)

^~ 是为 SemVer 量身定做的

这两个符号的语义依赖 "Major 才是真正的破坏性变更信号" 这一前提。脱离 SemVer 谈 ^~ 是没有意义的。

LTS 与 EOL

  • LTS(Long Term Support):长期支持版本,厂商承诺在较长一段时间(通常 2–5 年)内持续提供安全更新和 bug 修复
  • EOL(End of Life):停止支持的截止日期,过了 EOL 的版本不再接收任何官方更新
  • 生产环境往往偏好 LTS,因为它意味着更长的修复窗口和更可预测的升级路径

🧪 把上面这些凑在一起看

随手拿几个真实软件对照一下,应该能很快形成感觉:

软件版本号风格
React18.3.1标准 SemVer
Vue3.4.27标准 SemVer
Node.js20.13.1标准 SemVer,偶数 Major 为 LTS
Ubuntu24.04 LTSCalVer(YY.MM)+ 代号 + LTS 标记
Chromium126.0.6478.114SemVer 四段,最后一段相当于"构建号"
macOSSonoma 14.5代号 + 主版本
Docker 镜像 tagnginx:1.27.0-alpineSemVer + 基础镜像变体后缀

一句话总结

版本号不是"随便起一个数",而是"一组关于兼容性和变更幅度的承诺"。看到一组版本号,能读出"代际"、"成熟度"、"是否还在频繁改 API"、"构建自哪一次提交"——这才是版本号真正的价值 ✅

📌 几个容易忽略的细节(备查)

  • snapshot 是 Maven 专有名词,不是通用术语
  • 0.y.z 在 SemVer 里是有特殊语义的——它表示"未稳定",很多人会忽略这一点
  • 移动端的 versionCode / CFBundleVersion 本质是构建号而非版本号,应用商店强制递增
  • 代号派(macOS / Android / Ubuntu)一般会配合一个数字版本号同时出现,而不是单独使用

🪟 Windows 平台版本号实战

讲完抽象约定,回到具体平台。Windows 的版本号体系是业界最复杂的一档——它本身就有"两套"版本号,再加上 VS、CMake、qMake 三种主流构建系统各自的处理方式,初学者很容易搞混。

Windows 的两套版本号:文件版本 vs 产品版本

这是 Windows 资源文件(.rc)里的一个独特设计——同一个 .exe / .dll,可以同时携带两套版本号:

版本号资源字段形态用途
文件版本(File Version)FILEVERSION + FileVersion 字符串数字 + 字符串标识"这个具体文件的版本"
产品版本(Product Version)PRODUCTVERSION + ProductVersion 字符串数字 + 字符串标识"这个文件所属产品的版本"

数字形态

两者都是 MAJOR.MINOR.PATCH.BUILD 四段,每段范围 0~65535(一个 16 位 WORD)。

字符串形态

rc
VALUE "FileVersion",      "1.2.3.5"
VALUE "ProductVersion",   "1.2.3.0"

注意:虽然这两个字符串字段在技术上能写任意内容(例如 "v1.2.3 测试版"),但实际工程里它们就是数字字段的字符串镜像——保持和数字版完全一致。理由:

  • Windows 的 VerQueryValue / 安装包校验逻辑只认数字字段;字符串里写 1.2.3-beta,Windows API 比出来仍然是 1.2.3.0
  • 用户在文件属性里看到的版本号是字符串版本,如果和系统比较用的数字版不一致,会出现"我看到的是 1.2.3-beta,但安装包校验却说我装的是 1.2.3"这种令人困惑的局面
  • 预发布标签(-alpha-beta)属于抽象版本号规范(SemVer 等),不是 Windows 资源字段的语义——如果你的项目需要传递预发布概念,建议另开字段或者干脆走 SemVer,不要把它塞进 Windows 的 FileVersion 字符串

它们的关系与差异

最常见的误解:"文件版本 = 产品版本,所以随便填"。

实际上一旦产品里有多个 .exe / .dll,两者就会自然分开:

  • 产品版本:所有文件共享,反映"这套软件对外发布的版本"。例如 Office 16.0、Photoshop 25.0
  • 文件版本:每个文件独立,反映"这个文件具体编译到哪一版"。可能不同 dll 的 build 段各不相同
text
Microsoft Office 16.0
├── WINWORD.EXE    FileVersion: 16.0.4266.1001
├── EXCEL.EXE      FileVersion: 16.0.4266.1003
└── OUTLOOK.EXE    FileVersion: 16.0.4266.1005

用户看哪个?开发者看哪个?

  • 终端用户:基本只看"产品版本"("我用的是 Office 16 还是 Office 365")
  • 技术支持 / 调试:更关注"文件版本"——可以精确定位"是哪个 dll 出问题"
  • 应用商店 / 自动更新:同时读取两者做兼容判断

VERSIONINFO 资源长什么样

手写 .rc 时,结构如下:

rc
#include <windows.h>

VS_VERSION_INFO VERSIONINFO
FILEVERSION    1,2,3,5
PRODUCTVERSION 1,2,3,0
FILEFLAGSMASK  0x3fL
FILEFLAGS      0x0L
FILEOS         VOS__WINDOWS32
FILETYPE       VFT_APP
FILESUBTYPE    VFT2_UNKNOWN
BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "040904E4"
        BEGIN
            VALUE "CompanyName",      "MyCompany\0"
            VALUE "FileDescription",  "MyApp\0"
            VALUE "FileVersion",      "1.2.3.5\0"
            VALUE "InternalName",     "MyApp\0"
            VALUE "LegalCopyright",   "Copyright 2026\0"
            VALUE "OriginalFilename", "MyApp.exe\0"
            VALUE "ProductName",      "MyApp\0"
            VALUE "ProductVersion",   "1.2.3.0\0"
        END
    END
    BLOCK "VarFileInfo"
    BEGIN
        VALUE "Translation", 0x0409, 1252
    END
END

上面 0409 是语言 ID(英文 / 美式),04E4 / 1252 是字符集 ID。常见组合:

  • 0409 04E4:英文 + Unicode
  • 0804 04E4:简体中文 + Unicode
  • 0407 04E4:德文 + Unicode

三种构建系统的写法

1. Visual Studio(MSBuild)

VS 项目(.vcxproj)里设置版本号有两条路径

路径 A:通过 .rc 资源文件(最稳、所有 VS 版本通用)

直接把上面的 resource.rc 加到项目里,在 项目属性 → 配置属性 → 资源 → 常规 → 附加包含目录 配好头文件路径即可。编译器会自动把它编进 .exe

路径 B:通过 vcxproj 属性(VS 2019+ 简化方式)

xml
<PropertyGroup>
  <!-- 这一个值会被自动应用到 FileVersion / ProductVersion / AssemblyVersion -->
  <Version>1.2.3.5</Version>
</PropertyGroup>

AssemblyVersion ≠ Windows 资源版本

  • <AssemblyVersion>.NET 程序集版本,只对 C++/CLI 和 C# 项目有意义
  • 纯原生 C++ 项目里这个字段被忽略
  • 不要和 FileVersion / ProductVersion 混淆

2. CMake

CMake 提供了 project()VERSION 参数,会自动定义一组变量:

cmake
cmake_minimum_required(VERSION 3.20)
project(MyApp VERSION 1.2.3.5 LANGUAGES CXX)

自动可用的变量:

变量
PROJECT_VERSION"1.2.3.5"
PROJECT_VERSION_MAJOR1
PROJECT_VERSION_MINOR2
PROJECT_VERSION_PATCH3
PROJECT_VERSION_TWEAK5

CMake 不会自动生成 VERSIONINFO 资源

这是最常见的坑——project(VERSION ...) 设了版本号,但编译出来的 .exe 在文件属性里看不到任何版本号,因为 CMake 不会自动生成 .rc

正确做法:用 configure_file 把变量注入 .rc 模板。

text
#include <windows.h>

VS_VERSION_INFO VERSIONINFO
FILEVERSION    @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,@PROJECT_VERSION_TWEAK@
PRODUCTVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
FILEFLAGSMASK  0x3fL
FILEFLAGS      0x0L
FILEOS         VOS__WINDOWS32
FILETYPE       VFT_APP
FILESUBTYPE    VFT2_UNKNOWN
BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "080404E4"
        BEGIN
            VALUE "CompanyName",      "MyCompany\0"
            VALUE "FileDescription",  "MyApp\0"
            VALUE "FileVersion",      "@PROJECT_VERSION@\0"
            VALUE "InternalName",     "MyApp\0"
            VALUE "LegalCopyright",   "Copyright 2026\0"
            VALUE "OriginalFilename", "MyApp.exe\0"
            VALUE "ProductName",      "MyApp\0"
            VALUE "ProductVersion",   "@PROJECT_VERSION@\0"
        END
    END
    BLOCK "VarFileInfo"
    BEGIN
        VALUE "Translation", 0x0804, 1252
    END
END
cmake
if(WIN32)
    configure_file(${CMAKE_SOURCE_DIR}/version.rc.in
                   ${CMAKE_BINARY_DIR}/version.rc @ONLY)
    target_sources(MyApp PRIVATE ${CMAKE_BINARY_DIR}/version.rc)
endif()

3. qMake

qMake 的处理方式最省心——一个 VERSION 变量搞定一切:

pro
VERSION = 1.2.3.5

自动效果:

  • Linux / macOS:影响 .so / .dylib 的文件名和 SONAME
  • Windows:qMake 会自动生成 VERSIONINFO 资源,并把 VERSION 这个值同时填进 FILEVERSION 和 PRODUCTVERSION 两个字段——也就是说文件版本和产品版本在 qMake 里天然就是同一个值

qMake 实际生成的 .rc 片段(精简后)大致是这样的:

rc
#include <windows.h>

VS_VERSION_INFO VERSIONINFO
FILEVERSION    1,2,3,5
PRODUCTVERSION 1,2,3,5
FILEFLAGSMASK  0x3fL
FILEFLAGS      0x0L
FILEOS         VOS__WINDOWS32
FILETYPE       VFT_APP
BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "040904E4"
        BEGIN
            VALUE "CompanyName",      ""
            VALUE "FileDescription",  ""
            VALUE "FileVersion",      "1.2.3.5"
            VALUE "ProductName",      ""
            VALUE "ProductVersion",   "1.2.3.5"
        END
    END
    ...
END

可以看到 FILEVERSION、PRODUCTVERSION、FileVersion 字符串、ProductVersion 字符串四者全部用同一个 VERSION——这正是 qMake "省心" 的代价:你没办法在不放弃 qMake 自动生成的情况下,让文件版本和产品版本取不同的值

还可以用 qMake 自带的元数据变量:

pro
VERSION = 1.2.3.5
QMAKE_TARGET_PRODUCT     = MyApp
QMAKE_TARGET_COMPANY     = MyCompany
QMAKE_TARGET_DESCRIPTION = My Cool Application
QMAKE_TARGET_COPYRIGHT   = Copyright 2026

想让文件版本 ≠ 产品版本?

qMake 没有内置机制区分两者。两条路:

  1. 放弃 qMake 的自动生成:自己写一个 version.rc,然后在 .proRC_FILE = version.rc,qMake 就不再生成默认的 _win32.rc
  2. 简单项目直接接受两者相同:反正 Windows 自己也只在"一个产品多个文件"这种场景下才真正需要区分两者;如果你的项目就一个 .exe,两者写成一模一样的版本号完全没问题

三种构建系统的对比:

构建系统设置入口是否自动生成 rc是否能区分 File/Product
VSvcxproj <Version>.rc✅(2019+)⚠️ 需手写 rc 才能精细控制
CMakeproject(VERSION)❌ 必须自写 rc 模板✅ 完全可控
qMakeVERSION =❌ 默认两者相同

代码里怎么读取版本号

Windows API 提供两套入口——读数字版本和读字符串版本

cpp
#include <windows.h>
#include <vector>

// 读取数字版本(MAJOR.MINOR.PATCH.BUILD)
bool GetFileVersion(const std::wstring& path,
                    WORD& major, WORD& minor,
                    WORD& patch, WORD& build) {
    DWORD handle = 0;
    DWORD size = GetFileVersionInfoSizeW(path.c_str(), &handle);
    if (size == 0) return false;

    std::vector<BYTE> data(size);
    if (!GetFileVersionInfoW(path.c_str(), handle, size, data.data()))
        return false;

    VS_FIXEDFILEINFO* info = nullptr;
    UINT len = 0;
    if (!VerQueryValueW(data.data(), L"\\",
                        reinterpret_cast<LPVOID*>(&info), &len))
        return false;

    major = HIWORD(info->dwFileVersionMS);
    minor = LOWORD(info->dwFileVersionMS);
    patch = HIWORD(info->dwFileVersionLS);
    build = LOWORD(info->dwFileVersionLS);
    return true;
}

读取字符串版本(给用户看的那个):

cpp
struct LANGANDCODEPAGE { WORD wLanguage; WORD wCodePage; };

std::wstring GetStringFileVersion(const std::wstring& path) {
    DWORD handle = 0;
    DWORD size = GetFileVersionInfoSizeW(path.c_str(), &handle);
    std::vector<BYTE> data(size);
    if (!GetFileVersionInfoW(path.c_str(), handle, size, data.data()))
        return L"";

    LANGANDCODEPAGE* lc = nullptr;
    UINT len = 0;
    VerQueryValueW(data.data(), L"\\VarFileInfo\\Translation",
                   reinterpret_cast<LPVOID*>(&lc), &len);

    for (UINT i = 0; i < len / sizeof(LANGANDCODEPAGE); ++i) {
        wchar_t subBlock[64];
        swprintf_s(subBlock,
            L"\\StringFileInfo\\%04x%04x\\FileVersion",
            lc[i].wLanguage, lc[i].wCodePage);

        wchar_t* buf = nullptr;
        UINT bufLen = 0;
        if (VerQueryValueW(data.data(), subBlock,
                           reinterpret_cast<LPVOID*>(&buf), &bufLen)) {
            return std::wstring(buf, bufLen);
        }
    }
    return L"";
}

调用方式:

cpp
WORD maj, min, pat, bld;
if (GetFileVersion(L"myapp.exe", maj, min, pat, bld)) {
    wprintf(L"数字版本: %d.%d.%d.%d\n", maj, min, pat, bld);
}

auto str = GetStringFileVersion(L"myapp.exe");
wprintf(L"字符串版本: %s\n", str.c_str());

工程实践建议

跨构建系统统一版本号

如果项目同时被 CMake 和 qMake 构建,建议从外部注入,而不是在每个构建脚本里硬编码:

text
set MYAPP_VERSION=1.2.3.5
cmake -DMYAPP_VERSION=%MYAPP_VERSION% ...
qmake "VERSION = %MYAPP_VERSION%" ...
cmake
if(NOT DEFINED MYAPP_VERSION)
    set(MYAPP_VERSION "0.0.0.0")
endif()
project(MyApp VERSION ${MYAPP_VERSION} LANGUAGES CXX)

CI 里直接读 git tag,三套工具共享同一个版本源。

BUILD 段(第四段)怎么填

  • 普通发布:可以填 0
  • CI 出包:每次构建 +1,或用 git short hash 转十进制
  • 微软自家惯例:很多微软产品的 BUILD 是"自 2000-01-01 起的天数 × 1000 + 当天秒数 / 2",便于排序

移动端的"Windows 影子"

Windows 字段Android 对应iOS 对应
FileVersionversionCodeCFBundleVersion
ProductVersionversionNameCFBundleShortVersionString

移动端的"build 号"必须单调递增

应用商店只认数字,单调递增是硬要求。如果递减或重复,上传会被拒。

Windows 这一小节的一句话总结

Windows 的版本号是"两套数字 + 两套字符串"的组合。文件版本给"调试 / 技术支持"用,产品版本给"用户 / 营销"用;三种构建系统本质上都是把这些数字和字符串送进 .rc 资源里的不同容器——理解这一点,三种写法就都是同一件事的不同写法 ✅