移植类项目的版本管理
1、背景
-
现代软件系统越来越复杂,越来越大型化甚至巨型化,导致不得不模块化、组件化, 所以很多时候一个工程师并非开发整个系统,而仅开发其中一个模块或组件。 同时,随着芯片性能的极大提升,这个趋势也蔓延到与硬件打交道的
嵌入式
领域。 -
不少团队及个人在长期的开发过程中积累了大量实用的经验,既重新梳理了功能需求和改进性能, 又对不同平台、架构进行了适配,从而使部分组件脱颖而出,变得流行、通用, 最终的结果就是:当我们需要做一个类似功能的组件(或最终产品)时,往往不必再从零开发, 只需基于一个流行组件进行少量的修改,即可运行在目标硬件上,这个适配的操作就叫
移植
。 -
由于移植的重点是适配自己硬件的少量代码逻辑,而不是自己有、别人也有的原版组件, 何况这类通用的组件往往也比较复杂、庞大,所以应该纳入项目资产范围和代码版本库的是改动的部分, 原版组件则打包好另行储存即可,这就是此类项目的版本管理需求。
2、核心需求
-
少数改动的文件才是对自己项目有意义的,所以应该将这部分文件提取出来组成一个新项目( 后文将简称为
新项目
,同时将原来的组件称为基版
)。 -
既然是一个新项目,就意味着应有自己单独的版本管理措施,即: 每次版本号的更新应当仅因新项目的文件变化而触发,并且版本号的生成结果也与基版无关。
-
被编译的源码必须是基版与新项目叠加后的结果。作为一名有追求的工程师,
叠加
的操作肯定不能纯手工完成,而应该借助Makefile
或Shell
脚本, 否则这篇文章就不会出现。 -
若基版涉及图形化配置(典型例子是
Linux
内核的make menuconfig
), 则改动后的配置内容要反向同步到新项目中。与前一点相同,也要借助Makefile
或Shell
脚本。
3、具体实现
以U-Boot
、Linux
内核和基于Buildroot
的根文件系统为例进行讲解(仅核心逻辑)。
3.1 U-Boot
移植的版本管理
3.1.1 确定待改动的文件
假设目标项目采用NXP
公司的i.MX6ULL
为主控芯片,且基于其EVK
样板进行改造,
则至少需要改以下文件(从这里开始,直接放Makefile
内容):
# 若非指明,所有路径均是以基版项目(此处为U-Boot)根目录为起点。后同。
CUSTOM_FILES ?= configs/mx6ull_14x14_evk_nand_defconfig \
include/configs/mx6ullevk.h \
board/freescale/mx6ullevk/mx6ullevk.c
所以,新项目的目录结构类似这样:
新项目根目录
|-- board
| `-- freescale
| `-- mx6ullevk
| `-- mx6ullevk.c
|-- configs
| `-- mx6ull_14x14_evk_nand_defconfig
|-- include
| `-- configs
| `-- mx6ullevk.h
`-- Makefile
3.1.2 提供版本号的生成方法
可按照以下思路去探索版本号的生成逻辑:
-
基版项目是我们的根,所以它的版本号也必须沿用。但由于是一个新项目, 所以也必须附加自己的版本号,这个版本号才是重点。现在,问题转化为: 如何生成这个版本号,以及集成到基版项目。
-
首先研究如何生成的问题:这个因
版本管理系统
(即VCS
,譬如Git
、SVN
)而异。 以Git
为例,项目的每次提交均会生成一个散列码
(Hash
), 可以执行git log
、git describe
等命令来获取最近一次的散列码
。当然, 若能给出项目当前的状态是全部已提交还是有部分内容修改未提交的提示,就更好。 命令详情不打算在此展开,读者可直接查阅“懒编程秘笈”项目的makefile/__ver__.mk
。 -
还要研究如何集成的问题:这个只能查阅
U-Boot
的源码。打开U-Boot
根目录Makefile
搜索VERSION
碰碰运气, 很快就发现其版本号由VERSION
、PATCHLEVEL
、SUBLEVEL
、EXTRAVERSION
这几个变量组成, 形式为<VERSION>
[.<PATCHLEVEL>
[.<SUBLEVEL>
]]<EXTRAVERSION>
(方括号里的内容表示可有可无), 其中前两个可理解为主、次版本号且在Makefile
里有指定,后两个为空可供定制化。 看到这里,熟悉Makefile
语法规则和make
程序运行机制的同学, 很快就能反应过来在U-Boot
根目录下执行make EXTRAVERSION=xxx
即可将新项目的版本号集成进去, 而xxx
的具体内容源于前面的__ver__.mk
,所以只需在新项目Makefile
创建一个目标(Target
), 将这个语句写进去并加上切换到U-Boot
根目录的逻辑即可。
添加成功的版本号将编译进U-Boot
二进制文件,在启动的时候会打印出来,类似这样:
U-Boot 2016.03-af0d234d1b4d (Feb 05 2024 - 11:05:18 +0800)
3.1.3 指定改动内容如何叠加到基版项目
从Makefile
的依赖规则
入手,让基版项目某一文件依赖于提取到新项目的同一文件即可,简单举例如下:
SRC_ROOT_DIR ?= /path/to/uboot
${SRC_ROOT_DIR}/a.c: a.c
cp a.c ${SRC_ROOT_DIR}/a.c
这样,当新项目的a.c
有变动时,在编译时会自动将其复制过去覆盖基版的a.c
,
没改动则不会触发。如果有b.c
、c.c
等多个文件,理论上可以一个一个地如法炮制,但只有新手才允许这么做,
换成老手就显得很“戆居
”。因为,有经验的工程师至少应会使用模式规则
和自动变量
,
那么上述规则无论来几个文件都可以用一条规则搞掂,一次编写,永久运行,不受文件增删的影响,
写法如下:
# CUSTOM_FILES是上一小节的待改动文件列表
${CUSTOM_FILES}: ${SRC_ROOT_DIR}/%: %
cp $< $@
不过,方向虽然对了,但距离成功尚有一步之遥——因为上面的写法是错的……何解?
皆因%
表示的是茎
(stem
),在模式匹配中的位置可前而不可后(能放在最后的就叫做叶
了,
但目前Makefile
语法似乎不支持),所以需要另觅方案。经过多番尝试,得出一个个人认为较优雅又简单的写法,
就是利用多行变量
和eval
函数(若无特殊处理,eval
函数只能识别单行内容),写法如下:
# 先定义一个新的规则“函数”(Makefile里叫多行变量)
define custom_file_rule
${SRC_ROOT_DIR}/${1}: ${1}
cp ${1} ${SRC_ROOT_DIR}/${1}
endef
# 再利用eval函数来对将要改动的文件列表逐个动态生成依赖规则
$(foreach i, ${CUSTOM_FILES}, $(eval $(call custom_file_rule,${i})))
当然,以上只是核心逻辑的展示,实际的脚本还会有比较
(diff
)等操作,
以便直观显示出有哪些改动,有兴趣的可获取后文的链接去查阅全部内容。
3.1.4 自动保存更改后的编译配置(make menuconfig
的结果)
直接用代码说话:
DEFCONFIG ?= configs/mx6ull_14x14_evk_nand_defconfig
defconfig: ${SRC_ROOT_DIR}/${DEFCONFIG}
make $(notdir ${DEFCONFIG}) -C ${SRC_ROOT_DIR}
menuconfig: defconfig
make menuconfig -C ${SRC_ROOT_DIR}
cp ${SRC_ROOT_DIR}/.config ${DEFCONFIG}
有几点注意:
-
图形化配置通常不是从零开始,而是基于某个模板(这里是
mx6ull_14x14_evk_nand_defconfig
), 所以在make menuconfig
之前会先make defconfig
,而后者需要先应用改动后的模板,才能生成正确的.config
文件。 -
让
defconfig
目标依赖于${SRC_ROOT_DIR}/${DEFCONFIG}
,根据前一小节已定义的叠加规则, 即可自动搬运有改动的模板文件到基版项目里进行覆盖。 -
以上仅展示核心逻辑来说明原理,完整版的内容还会有判断是否使用模板、 保存之前检查是否存在
.config
文件以及比较
(diff
)等操作。
3.1.5 项目链接
3.2 Linux
内核移植的版本管理
与前面的U-Boot
思路及实现大同小异,但版本号的处理大不一样,因为内核的版本号不能随意增、删、改,
否则会影响到驱动程序的编译和诸多第三方程序或脚本的正常运行,所以需要另想办法。
首先在源码根目录的Makefile
里找线索,最终找到一个KCFLAGS
环境变量可供用户定制化,
即是说可通过该变量添加额外的编译选项,当然也包括新项目版本号的宏定义。其实,
这个变量在内核文档Documentation/kbuild/kbuild.txt
(较新版文件名是kbuild.rst
)也有正式说明,
可以放心使用。
接下来就要看能在什么地方安插自己的版本号。Linux
用户都知道使用uname
命令可查看系统版本号,
所以只需看它如何与内核交互,即可知道应该修改什么地方。通过阅读内核源码可知道,
与uname
有关的procfs
接口逻辑在init
子目录,里面的init/version*.c
使用若干个UTS_*
宏来组成内核版本号的值,
继续追踪下去会发现UTS_SYSNAME
(定义系统名称,例如Linux
)、UTS_NODENAME
(定义节点名称,约等于主机名)、
UTS_DOMAINNAME
(定义域名,一般用不上,所以默认值为(none)
)支持定制化(默认值定义在include/linux/uts.h
),
而UTS_RELEASE
(发行版的主号,例如4.1.15
)、UTS_VERSION
(每次编译均会刷新,
含有编译次数、内核是否可抢占、编译时间等信息)、UTS_MACHINE
(与架构相关,例如arm
)则在编译期动态生成。
若用命令来查询这些宏的值则有:UTS_SYSNAME
可用uname -s
、UTS_NODENAME
可用uname -n
、
UTS_DOMAINNAME
可用cat /proc/sys/kernel/domainname
、UTS_RELEASE
可用uname -r
、
UTS_VERSION
可用uname -v
、UTS_MACHINE
可用uname -m
。综上,能被利用而不产生副作用的只有UTS_NODENAME
、
UTS_DOMAINNAME
和UTS_VERSION
。其中,前两个由于支持定制化,
所以可以利用前面的KCFLAGS
环境变量传递两个可被gcc
识别的-D
选项值;
而UTS_VERSION
虽然在名义上和形式上都是最合适的候选者,但因其值来源是编译期动态生成,
所以只有找出修改信息源的办法,才有可能把用户版本号注入其中。幸运的是,
UTS_VERSION
的序号
值(编译次数)是由KBUILD_BUILD_VERSION
变量或根目录.version
文件提供,
而前者支持用户赋值。所以,最终的版本号生成逻辑如下:
# __VER__和VCS变量由前面的__ver__.mk提供
export KBUILD_BUILD_VERSION ?= ${__VER__}
KCFLAGS ?= -DUTS_NODENAME=\\\"`hostname`[${__VER__}]\\\" -DUTS_DOMAINNAME=\\\"${VCS}://ver.${__VER__}.nil/\\\"
添加成功的版本号将编译进Linux
镜像文件,在进入系统后可使用命令来查看,类似这样:
$ uname -v # 优先考虑这个命令
#5d2417e3f04a SMP PREEMPT Wed Feb 7 22:58:22 CST 2024
$
$ cat /proc/sys/kernel/domainname # 若上一个命令无效,则使用这个
git://ver.5d2417e3f04a.nil/
$
# “uname -n”通常会输出文件系统配置的主机名,覆盖掉编译时指定的宏定义
关于KBUILD_BUILD_VERSION
变量还可以稍微展开说说。这个变量出现的位置,
在较旧版本(例如4.1.15
)是在scripts/mkcompile_h
,在较新版本则是init/Makefile
,
并且在内核文档里无正式说明(不知是维护者懒得更新还是别的原因),让人用得不放心,
所以前面的版本号生成逻辑才同时使用UTS_NODENAME
和UTS_DOMAINNAME
,为了是多两道保险(实则只有一道),
万一将来某天内核维护者脑子抽风取消或修改掉这个变量,不至于没有地方存放用户版本号。
最后,给出完整内容的链接如下:
3.3 基于Buildroot
的根文件系统制作的版本管理
与前面的U-Boot
思路及实现大同小异,只需找出与版本相关的变量即可。照旧在根目录Makefile
搜索VERSION
碰碰运气,
发现了以下版本号逻辑:
92 # Set and export the version string
93 export BR2_VERSION := 2023.02
...
112 # Compute the full local version string so packages can use it as-is
113 # Need to export it, so it can be got from environment in children (eg. mconf)
114
115 BR2_LOCALVERSION := $(shell $(TOPDIR)/support/scripts/setlocalversion)
116 ifeq ($(BR2_LOCALVERSION),)
117 export BR2_VERSION_FULL := $(BR2_VERSION)
118 else
119 export BR2_VERSION_FULL := $(BR2_LOCALVERSION)
120 endif
...
720 .PHONY: target-finalize
721 target-finalize: $(PACKAGES) $(TARGET_DIR) host-finalize
...
757 mkdir -p $(TARGET_DIR)/etc
758 ( \
759 echo "NAME=Buildroot"; \
760 echo "VERSION=$(BR2_VERSION_FULL)"; \
761 echo "ID=buildroot"; \
762 echo "VERSION_ID=$(BR2_VERSION)"; \
763 echo "PRETTY_NAME=\"Buildroot $(BR2_VERSION)\"" \
764 ) > $(TARGET_DIR)/usr/lib/os-release
765 ln -sf ../usr/lib/os-release $(TARGET_DIR)/etc
可知与BR2_VERSION
、BR2_LOCALVERSION
、BR2_VERSION_FULL
这三个变量有关。
其中,BR2_VERSION
虽然在Makefile
里直接赋值,但仍可通过命令行变量来覆盖,即支持定制化;
BR2_LOCALVERSION
由support/scripts/setlocalversion
脚本生成,查看脚本内容可知取的是VCS
的版本号,
将Buildroot
项目根目录下的.svn
或.git
目录删除即可使其失效;BR2_VERSION_FULL
则取前两者之一。
并且,版本号信息会写入一个文件,默认是/usr/lib/os-release
及其软链接/etc/os-release
。
所以,最终的版本号生成逻辑如下:
PKG_VERSION ?= 2023.02
MAKE_ARGS := $(if ${__VER__},BR2_VERSION=${PKG_VERSION}-${__VER__})
SRC_ROOT_DIR ?= /path/to/buildroot
all:
${MAKE} -C ${SRC_ROOT_DIR} ${MAKE_ARGS}
添加成功的版本号将会写入根文件系统,可打开相应的文件来查看,类似这样:
$ cat /etc/os-release
NAME=Buildroot
VERSION=2023.02-194f88039230
ID=buildroot
VERSION_ID=2023.02-194f88039230
PRETTY_NAME="Buildroot 2023.02-194f88039230"
此外,Buildroot
由于涉及众多项目的集成,所以还会涉及保存BusyBox
编译配置、添加自定义目录及文件、
制作BusyBox
显示中文的补丁文件等需求,后面完整版的Makefile
将会给出针对这些内容的逻辑,
至于原理则可参考《嵌入式根文件系统构建实录》
和《Buildroot及BusyBox深度排雷》这两篇文章。
最后,给出完整内容的链接如下: