我们自己使用的服务器,经常需要编译代码等操作,并且是多人并行使用的服务器状态,使用久了的情况下,服务器经常会出现如下问题。
对于我这么多年使用服务器的经验而言,其实一直都是在平衡上面五个问题的状态,让其在上面五个问题中间得到平衡,而不因为某个点导致服务器的性能下降。
可见对于不同场景的服务器,需要为其设置不同的内存调节状态,本文基于编译型服务器,提出关于内存调节的一下想法和见解。这会对使用服务器有更好的理解。
只有拥有一个调教好的服务器,才能发挥利用服务器发挥自己最大的工作效率
linux默认配置swappiness是60,通常情况下,这个值的范围是0-100,值越高,则回收的时候优先选择匿名页,然后将匿名页搬运到swap分区中,值越低,会减少swap的使用,从而由其他参数调节page cache的行为
为什么首先谈这个呢。根据上面理解的,swappiness默认是60,那么从某种平衡上来看,如果系统存在内存压力的情况下,会稍微将anon page移到swap 分区中。那么将anon page移到swap 分区因为IO带来的性能损失和延迟对绝大部分人是能够接收的,这样就不至于可能出现系统完全无内存导致的oom情况。
根据默认值60,我们知道,linux内核考虑了很通用的情况下,牺牲一些性能从而满足内存在压力情况下的可用。
而面对服务器而言,特别是编译型服务器,我们每个编译任务可能是长时间,但一定会完成的,所以不希望移到swap分区,如果维持在60的值,大型任务例如llvm,chromium的构建就会很慢。所以需要降低swappiness的值
那我们设置swappiness的值是0还是1呢。根据长时间的经验来看,总结如下
可以看到,根据使用经验而言,swappiness在编译型服务器上,应该很低,但是如果设置为0,那么swap分区即使设置了很大,内核还是不会使用swap分区的空间,而是直接oom。那么推荐swappiness的值是1。当内存不够的时候,才将swap分区利用起来
根据swappiness的配置,我们能够解决默认情况下编译代码缓慢的问题,因为系统不再将anon page移动到swap 分区中了,也不会突然因为不当的swappiness设置导致oom情况出现,编译速度得到了加快。
根据上面的设置,系统默认不使用swap分区了,那么所有的任务都更倾向于使用内存了,对于很多人而言,都了解一个配置 drop_caches ,认为drop_caches能够通过定期任务减缓内存压力。
按照本人理解,其实 drop_caches 是清空page cache和slab cache
如果我们echo 1 > /proc/sys/vm/drop_caches
那么情况的是page cache。 具体而言是清空处于inactive链表的page cache,实验如下:
未清空page cache如下
root@kylin:~# cat /proc/meminfo | grep -i active Active: 1361888 kB Inactive: 46626572 kB Active(anon): 843576 kB Inactive(anon): 387512 kB Active(file): 518312 kB Inactive(file): 46239060 kB
此时我们运行# echo 1 > /proc/sys/vm/drop_caches
得到信息如下
root@kylin:~# cat /proc/meminfo | grep -i active Active: 1023844 kB Inactive: 438784 kB Active(anon): 862328 kB Inactive(anon): 368504 kB Active(file): 161516 kB Inactive(file): 170280 kB
可以看到,处于inactive上的46G内存得到了释放
接下来看drop_caches对slab的清空情况。首先我们知道slab的值是SReclaimable+SUnreclaim。而同样的drop_caches针对的是可回收的内存,也就是SReclaimable,而不是不可回收的内存SUnreclaim,演示如下
root@kylin:~# cat /proc/meminfo | grep -i slab -A 2 Slab: 3724284 kB SReclaimable: 3116412 kB SUnreclaim: 607872 kB root@kylin:~# echo 2 > /proc/sys/vm/drop_caches root@kylin:~# cat /proc/meminfo | grep -i slab -A 2 Slab: 602276 kB SReclaimable: 115296 kB SUnreclaim: 486980 kB
可以看到,对于SReclaimable对于的内存,得到了回收。
实际上,对于编译型服务器,这里更多是dentry cache,也就是文件系统访问缓存,我们演示一下如下
# cat /proc/sys/fs/dentry-state 97122 77478 45 0 53596 0 # echo 2 > /proc/sys/vm/drop_caches && cat /proc/sys/fs/dentry-state 17018 23 45 0 1065 0
可以看到,nr_unused从7万变为23了,内存从这里得到了释放
针对此,很多人,包括早期我自己也很喜欢定期执行 sync和drop_cachessync && echo 1 > /proc/sys/vm/drop_caches
。 sync的目的是将脏页写回,这样可回收的内存就会变多。但是根据上面的了解,其缺点也很明显,针对编译型服务器,其dentry cache得到了错误的清除,导致编译速度变慢了,不仅如此,服务器还会出现概率卡死。
但实际上,如果我们了解内存的配置,其实无需定期执行,而是由kernel的self tuning。会比定期执行更好。
根据上面提到的drop_caches,我们先讨论page caches的回收,这里借助lorenzo stoakes编写《the linux memory manager》一张图如下
根据此图,可以看到很熟悉的一个机制,那就是linux 水位机制,这里简单回顾一下
可以知道,如果内存压力太大,那么内核会自己回收内存,而不需要自己定期的drop_caches
其二,对于slab cache的self tuning方式,如下
这里可以看到,slab默认的shrink机制能够自动回收slab cache。其机制也属于关于slab的indirect/direct reclaim回收,所以也符合水位机制。
根据上面介绍的,我们可以针对性的调整水位的比例。
针对编译型服务器,根据多年的使用经验,那么可能出现如下问题
可以看到,默认的配置还是不满足编译型服务器,我们需要针对性的修改sysctl,如下
对于min一下无可用内存,直接oom的情况,我们可以修改min_free_kbytes的值,通常min_free_kbytes的值也是内核通过当前所有zones的总和计算出来的,为了考虑常规情况,此值通常偏低了点,为了应对编译型服务器,那么需要设置高点,多高呢。我们可以对照内核的init_per_zone_wmark_min函数查看
int __meminit init_per_zone_wmark_min(void) { ...... min_free_kbytes = new_min_free_kbytes; if (min_free_kbytes < 128) min_free_kbytes = 128; if (min_free_kbytes > 262144) min_free_kbytes = 262144; ...... return 0; }
可以知道,内核最大可以设置到256M,鉴于我们是服务器,内存本身就很大,所以我们可以设置为256,如下
echo 262144 > /proc/sys/vm/min_free_kbytes
不过这个是根据实际情况,太大了也不一定好,个人建议设置到0.05%。也就是对于128G内存,设置到64M。
设置完了之后,可以长期监听vmstat的kswapd_low_wmark_hit_quickly 来判断你的服务器设置的值是否合理,如下
# cat /proc/vmstat | grep kswapd_low_wmark_hit_quickly kswapd_low_wmark_hit_quickly 358635
这个值代表kswapd到low水位的次数,这里我已经到358635次了。这里值得注意的是,这个次数不是越高越好,也不是越低越好。是根据情况查看增长速率,如下:
这样,我们当内存不够的时候,我们就不会出现无法direct reclaim导致的oom情况了。
如果内存从low到min速度太快,又因为直接回收是阻塞的,所以我们经常发现服务器突然卡死了。 通常这种情况下,如果没有什么重要的事情,那么等一会儿就好了。
直接解决此问题的方式就是增加内存,例如32G的内存可以增加到128G,128G的内存可以增加到256G。
这里讨论的是既不想等一会儿自己恢复,又短时间无法增加服务器内存的情况。
针对这种情况,解决方案也很容易想到,我们扩大low到min的距离,也就是,假设min是64M,那我low是640M行不行呢?
很可惜的是,内核只提供了比例设置,不提供具体的值的设置。我们可以设置watermark_scale_factor来缓解这个问题
watermark_scale_factor控制了kswapd的主动性,默认其分母是10000,其值设置是10,那么是0.1%比率作为计算,也就是high与low的差距是总内存的0.1%。
针对此问题,缓解的方法是设置watermark_scale_factor大一点,具体多大呢,需要根据如下值的增长情况判断,
root@kylin:# cat /proc/vmstat |grep 'allocstall' allocstall_dma32 0 allocstall_normal 173606 allocstall_movable 4338981
这里arm64没有ZONE_DMA,不用奇怪,我们监控normal和movable的allocstall值,如果这个值变大,那么需要将watermark_scale_factor放大。
echo 40 > /proc/sys/vm/watermark_scale_factor
根据经验,调整watermark_scale_factor并不能改善无响应的问题,因为内存不够就是不够,只是改善进入直接回收的时机,也就是扩大水位线之间的差距,从而减少系统无响应的次数
默认情况下,内核设置了overcommit_memory的值是0,意味着如果没有可用内存,则申请内存失败。它会影响到一些大型任务构建是否成功。
也就是说,有时候会因为内存不够,导致某些程序无法编译成功,所以有些情况下,我们需要设置overcommit_memory为1如下
echo 1 > /proc/sys/vm/overcommit_memory
这里含义如下
值得注意的是,如果设置为1,那么可能会导致oom,理由可想而知。
上面已经说明清楚了内核关于内存的回收流程,但服务器的配置光内存的回收还不够,我们需要控制脏页的回收策略。
对于脏页的回收设置,有一个文章推荐设置具体的值,而不是比率。如下
所以我们设置脏页回收策略根据值来配置。
vm.dirty_ratio = 0 vm.dirty_bytes = 629145600 # keep the vm.dirty_background_bytes to approximately 50% of this setting vm.dirty_background_bytes = 314572800
值得注意的是默认情况下,设置的dirty_bytes是0,而dirty_ratio是20。所以对于编译型服务器而言,这里必须修改一下。
上面可以看到slab的SReclaimable回收来源于dentry,因为我们是编译型服务器,所以我们需要调整vfs_cache_pressure。
vfs_cache_pressure的作用是回收文件系统缓存,如果我们内存吃紧,还容易出现OOM,那么就增高此值,如果内存足够,则降低此值。
为了让服务器能够编译,并且保留缓存,我更倾向于降低此值。
echo 50 > /proc/sys/vm/vfs_cache_pressure
如果你编译大型任务,发现出现OOM了,那么就增大此值
echo 500 > /proc/sys/vm/vfs_cache_pressure
至此,我总结了这些年和服务器斗智斗勇的这些配置,在不同情况下,需要设置的不一样。 对于从事操作系统开发而言,成千上万的软件包要构建和开发,那么服务器就是纯编译型服务器,那么这种情况下针对内存情况的配置如下。
如果内存吃紧,那么如下配置
echo 1 > /proc/sys/vm/swappiness echo 262144 > /proc/sys/vm/min_free_kbytes echo 40 > /proc/sys/vm/watermark_scale_factor echo 1 > /proc/sys/vm/overcommit_memory echo 629145600 > /proc/sys/vm/dirty_bytes echo 314572800 > /proc/sys/vm/dirty_background_bytes echo 500 > /proc/sys/vm/vfs_cache_pressure
值得注意的是
这样设置的情况下,编译任何工程都能工作
如果服务器内存本来就多,这个问题就变得简单了
1. 无需swap分区 2. 增大dirty_bytes 3. vfs_cache_pressure设置为1
当然,那不现实,写到这里,不禁发出感叹:
对于开头提到的问题解决效果呢,如下
systemtap是一个基于linux的性能诊断工具,能够对linux内核函数和linux应用的运行细节进行诊断的工具,最近遇到一个函数调用延迟比较大的问题,针对此问题,如果从庞大的代码中按照行分析,可能花费时间成本较大。我建议可以使用systemtap来定位进程的任意代码的运行耗时。这样就很轻松的定位性能问题了。下面就来介绍如何在arm64下使用systemtap定位程序的函数执行性能问题
apt install systemtap-sdt-dev libdw-dev
这里注意,默认系统的systemtap版本过旧,我们需要从systemtap官网下载,当前最新的是systemtap-5.3。
git clone git://sourceware.org/git/systemtap.git
如果sourceware速度慢,可以换清华源镜像站
wget https://mirrors.tuna.tsinghua.edu.cn/sourceware/systemtap/releases/systemtap-5.3.tar.gz
默认最新的代码是基于6.15-rc的内核,我们实际上内核使用的5.10。所以有一些代码需要稍微适配一下。
arm64内核从5之后都开启了vfs的namespace,如果不做额外修改,在arm64上,如下两个函数会报命名空间问题
kernel_read filp_open
报错信息如下
ERROR: modpost: module stap_ce5dbcb79b603543094c4f68fb16a1a8_943 uses symbol kernel_read from namespace VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver, but does not import it. ERROR: modpost: module stap_ce5dbcb79b603543094c4f68fb16a1a8_943 uses symbol filp_open from namespace VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver, but does not import it.
对于此问题,我们需要为systemtap在runtime的代码上声明一下命名空间,如下
# vim runtime/transport/symbols.c MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver);
对于6的内核默认实现timer_delete_sync函数,但是我们还是在5.10的内核,我们使用的是del_timer_sync函数,所以需要针对就内核修改systemtap的代码,位置在runtime/transport/relay_compat.h
。
未修改代码如下
#ifdef STAPCONF_DEL_TIMER_SYNC #define STP_TIMER_DELETE_SYNC(a) del_timer_sync(a) #else #define STP_TIMER_DELETE_SYNC(a) timer_delete_sync(a) #endif
这里宏STAPCONF_DEL_TIMER_SYNC会决定具体的函数实现,我们为了代码修改最小化,如下修改
#ifndef STAPCONF_DEL_TIMER_SYNC #define STP_TIMER_DELETE_SYNC(a) del_timer_sync(a) #else #define STP_TIMER_DELETE_SYNC(a) timer_delete_sync(a) #endif
这里简单的修改了宏定义的作用范围
编译方法之前提过,如下
./configure make all -j8 make install -j8
systemtap代码量不算很大,可以之间在机器里面编译。这样make install就直接安装成功了
如果安装成功,那么我们可以看到如下信息
# stap --version Systemtap translator/driver (version 5.3/0.176, non-git sources) Copyright (C) 2005-2025 Red Hat, Inc. and others This is free software; see the source for copying conditions. tested kernel versions: 3.10 ... 6.15-rc enabled features: BPF LIBSQLITE3 LIBXML2 NLS JSON_C
我们需要使用systemtap,那么内核需要打开调试功能,整理如下
CONFIG_DEBUG_INFO=y CONFIG_KPROBES=y CONFIG_UPROBES=y CONFIG_RELAY=y CONFIG_DEBUG_FS=y CONFIG_MODULES=y CONFIG_TRACEPOINTS=y CONFIG_FUNCTION_TRACER=y
systemtap的原理是通过在系统中安插一个ko,通过此ko获取系统的详细信息,所以我们需要在内核中预置头文件。
手动预置的办法如下
make headers_install INSTALL_HDR_PATH=/tmp/kernel-header/ make firmware_install INSTALL_MOD_PATH=/tmp/kernel-header/ make modules_install INSTALL_MOD_PATH=/tmp/kernel-header/ cp --parents `find -type f -name "Makefile*" -o -name "Kconfig*"` /tmp/kernel-header/ cp Module.symvers /tmp/kernel-header/ cp System.map /tmp/kernel-header/ cp -rf scripts/ /tmp/kernel-header/ # arm bin cp -rf include/ /tmp/kernel-header/ cp -rf --parents arch/arm64/include /tmp/kernel-header cp -rf --parents arch/arm/include /tmp/kernel-header cp .config /tmp/kernel-header/ tar cvzf /tmp/kernel-header.tar.gz /tmp/kernel-header
此时我们将/tmp/kernel-header.tar.gz
解压为目录/usr/src/linux-headers-$(uname -r)
然后我们建立头文件链接如下
mkdir /lib/modules/${uname -r}/ ln -sf /usr/src/linux-headers-$(uname -r) build
此时,我们可以在机器中编译ko文件了
如果觉得上述手动预置不方便,那么可以自己从内核打包headers的deb,如下
make bindeb-pkg -j256
注意,上面是在已经构建过内核的情况下,这里只打包。如果没构建过内核,建议从头开始
make deb-pkg -j256
此时我们获得如下文件安装
dpkg -i linux-headers-5.10.198_5.10.198-69_arm64.deb linux-image-5.10.198_5.10.198-69_arm64.deb linux-image-5.10.198-dbg_5.10.198-69_arm64.deb linux-libc-dev_5.10.198-69_arm64.deb
为了能够获取应用程序的符号用于调试,我们需要安装对应应用程序的符号包,如下
# dpkg -i kylin-nm-dbgsym_3.20.1.7_arm64.ddeb
此时我们stap可以获取函数的符号如下
# stap -l 'process("/usr/bin/kylin-nm").function("*")' process("/usr/bin/kylin-nm").function("onShowControlCenter@frontend/tab-pages/lanpage.cpp:723") process("/usr/bin/kylin-nm").function("onSwithGsettingsChanged@frontend/tab-pages/lanpage.cpp:173") process("/usr/bin/kylin-nm").function("onUpdateConnection@frontend/tab-pages/lanpage.cpp:1150") process("/usr/bin/kylin-nm").function("onWiredEnabledChanged@frontend/tab-pages/lanpage.cpp:1233") ......
至此,我们可以开始调试了。
内核头文件完成之后,我们可以编写stap文件来进行调试。对于当前的需求是
所以代码如下:
# cat kylin.stp global entry_times probe process("/usr/bin/kylin-nm").function("LanPage::onWiredEnabledChanged") { entry_time = gettimeofday_us() entry_times[pid()] = entry_time } probe process("/usr/bin/kylin-nm").function("LanPage::onWiredEnabledChanged").return { if (entry_times[pid()] != 0) { exit_time = gettimeofday_us() elapsed = exit_time - entry_times[pid()] printf("[PID %d] [%s]: Took %ld us \n", pid(), "LanPage::onWiredEnabledChanged", elapsed) delete entry_times[pid()] } }
根据上面内容我们可以知道,这里我想定位/usr/bin/kylin-nm
的LanPage::onWiredEnabledChanged
函数的耗时。
我们如下方式运行
# stap -v ./kylin.stp Pass 1: parsed user script and 467 library scripts using 590948virt/103640res/5892shr/130664data kb, in 530usr/200sys/247real ms. Pass 2: analyzed script: 2 probes, 3 functions, 1 embed, 1 global using 598732virt/112916res/7120shr/138448data kb, in 330usr/10sys/339real ms. Pass 3: translated to C into "/tmp/stapbjEzxz/stap_44d2d295e641497a8b3e84b98ae25516_2499_src.c" using 598732virt/113108res/7312shr/138448data kb, in 10usr/240sys/252real ms. Pass 4: compiled C into "stap_44d2d295e641497a8b3e84b98ae25516_2499.ko" in 43320usr/8190sys/12830real ms. Pass 5: starting run.
可以看到有[PID 89425] [LanPage::onWiredEnabledChanged]: Took 174 us
的日志,这里可以看到按钮的响应时间是174us。
我们再点击关闭网络,如下
再点击打开网络,如下
反复10次,此时日志如下
[PID 89425] [LanPage::onWiredEnabledChanged]: Took 279 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 502 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 632 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 617 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 616 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 656 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 146 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 280 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 640 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 576 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 501 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 642 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 647 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 670 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 289 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 603 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 619 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 453 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 594 us [PID 89425] [LanPage::onWiredEnabledChanged]: Took 276 us
至此,我们可以监控任意的函数的执行时间。
如果需要定位其他的程序,就打上其他程序符号,找到需要定位的函数,修改kylin.stp即可
本文演示了在arm64上使用systemtap定位任意函数的耗时问题。systemtap还可以定位内核和其他问题。这里就不额外解释了。有兴趣可以自己研究,参与开源。
值得注意的是,如果systemtap在你的内核环境上运行不起来,你可能需要根据其分析兼容性问题。本文为了让systemtap-5.3在linux 5.10上运行,修改了两处代码的兼容问题。实际需要修复的内容通常是版本演变带来的问题,不会太困难。
asan提供了定位全局对象的构造顺序相关的方法,本文详细了解一下关于c/c++全局变量的构造顺序带来的问题
为了实施这个bug,我们需要两个cpp文件,其代码如下。
# cat initialization_order_fiasco_1.cpp #include <stdio.h> extern int extern_global; int __attribute__((noinline)) read_extern_global() { return extern_global; } int x = read_extern_global() + 1; int main() { printf("%d\n", x); return 0; } # cat initialization_order_fiasco_2.cpp int foo() { return 2; } int extern_global = foo();
根据上面的代码,我们知道有两个全局变量extern_global和x
此时我们通过修改编译顺序来复现问题
g++ -g initialization_order_fiasco_1.cpp initialization_order_fiasco_2.cpp -o initialization_order_fiasco_1_2 g++ -g initialization_order_fiasco_2.cpp initialization_order_fiasco_1.cpp -o initialization_order_fiasco_2_1
此时运行initialization_order_fiasco_1_2,如下
# ./initialization_order_fiasco_1_2 1
如果运行initialization_order_fiasco_2_1,如下
# ./initialization_order_fiasco_2_1 3
可以发现,因为我们g++传入文件的顺序不一致,而两个cpp中关于全局变量extern_global存在依赖,所以就导致了问题产生,按照我们理想的想法,这里的值应该是3,但是有可能是1
我们将得出正常结论的编译方式加入asan来检测,如下
g++ -fsanitize=address -g initialization_order_fiasco_2.cpp initialization_order_fiasco_1.cpp -o asan_2_1
注意,这里先加入initialization_order_fiasco_2.cpp后加入initialization_order_fiasco_1.cpp,也就是说先声明extern_global,再使用extern_global和声明x。
此时运行asan检测,没有上报问题。
现在我们将编译顺序调换,先声明x和使用extern_global,再声明extern_global
g++ -fsanitize=address -g initialization_order_fiasco_1.cpp initialization_order_fiasco_2.cpp -o asan_1_2
此时运行程序
# LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libasan.so.5.0.0 ASAN_OPTIONS=check_initialization_order=true ./asan_1_2 ================================================================= ==133524==ERROR: AddressSanitizer: initialization-order-fiasco on address 0x0000004121e0 at pc 0x000000400948 bp 0x007fc3aff370 sp 0x007fc3aff390 READ of size 4 at 0x0000004121e0 thread T0 #0 0x400944 in read_extern_global() /root/asan/initialization_order/initialization_order_fiasco_1.cpp:4 #1 0x400a10 in __static_initialization_and_destruction_0 /root/asan/initialization_order/initialization_order_fiasco_1.cpp:6 #2 0x400a90 in _GLOBAL__sub_I__Z18read_extern_globalv /root/asan/initialization_order/initialization_order_fiasco_1.cpp:10 #3 0x400c54 in __libc_csu_init (/root/asan/initialization_order/asan_1_2+0x400c54) #4 0x7fa15efd34 in __libc_start_main (/lib/aarch64-linux-gnu/libc.so.6+0x20d34) #5 0x400828 (/root/asan/initialization_order/asan_1_2+0x400828) 0x0000004121e0 is located 0 bytes inside of global variable 'extern_global' defined in 'initialization_order_fiasco_2.cpp:2:5' (0x4121e0) of size 4 registered at: #0 0x7fa17b7a10 (/usr/lib/aarch64-linux-gnu/libasan.so.5.0.0+0x3aa10) #1 0x400bec in _sub_I_00099_1 (/root/asan/initialization_order/asan_1_2+0x400bec) #2 0x400c54 in __libc_csu_init (/root/asan/initialization_order/asan_1_2+0x400c54) #3 0x7fa15efd34 in __libc_start_main (/lib/aarch64-linux-gnu/libc.so.6+0x20d34) #4 0x400828 (/root/asan/initialization_order/asan_1_2+0x400828) SUMMARY: AddressSanitizer: initialization-order-fiasco /root/asan/initialization_order/initialization_order_fiasco_1.cpp:4 in read_extern_global() Shadow bytes around the buggy address: 0x0010000823e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x0010000823f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082400: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082410: f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 f9 0x001000082420: 00 00 f9 f9 f9 f9 f9 f9 f9 f9 00 00 00 00 00 00 =>0x001000082430: 04 f9 f9 f9 f9 f9 f9 f9 00 00 00 00[f6]f6 f6 f6 0x001000082440: f6 f6 f6 f6 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082470: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x001000082480: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 Shadow byte legend (one shadow byte represents 8 application bytes):
根据上面的信息,得出如下结论
根据上面的信息,我们返回代码补充一个信息
int extern_global = foo();
默认情况下其值是2所以我们根据上面已有信息,我们就清晰的定位出来extern_global的初始化顺序存在问题。
补充一下,关于0x0000004121e0,因为是全局变量,所以我们在运行前就可以获取验证一下, 确定asan报错是正常的。
# objdump -d -j .bss ./asan_1_2 00000000004121e0 <extern_global>:
通过上面的内容,我们定位了extern_global的初始化顺序存在问题。其问题出现的原因在于g++的编译顺序。
我们先拆解g++的编译步骤,我们先编译.o文件
g++ -g -c initialization_order_fiasco_1.cpp -o 1.o g++ -g -c initialization_order_fiasco_2.cpp -o 2.o
此时针对1.o和2.o,我们读取其bss端的值如下
# objdump -d -j .bss 1.o 0000000000000000 <x>: 0: 00 00 00 00 # objdump -d -j .bss 2.o 0000000000000000 <extern_global>: 0: 00 00 00 00
可以看到,在编译阶段,全局变量加载地址还是0,它需要在链接阶段由ld填充实际地址。问题不出在编译阶段,那么接下来我们链接两个o文件
g++ 1.o 2.o -g -o 1_2 g++ 2.o 1.o -g -o 2_1
此时我们得到两个文件1_2/2_1。 对于libc函数调用的流程可以查看文章《程序的启动过程浅析》,静态变量会运行__static_initialization_and_destruction_0
函数来初始化静态全局变量的值,对于glibc的流程如下
_start __libc_start_main __libc_csu_init _GLOBAL__sub_I_XXX (gcc) __static_initialization_and_destruction_0
这里需要注意的是_GLOBAL__sub_I_XXX
是gcc为每个cpp文件生成的用于构造静态全局变量的构造函数,它的运行顺序就是每个cpp的添加顺序。
对于1_2程序,根据上面的推论,我们可以猜测其运行顺序如下
__libc_csu_init _GLOBAL__sub_I__Z18read_extern_globalv __static_initialization_and_destruction_0 _GLOBAL__sub_I__Z3foov __static_initialization_and_destruction_0
那么就是
int x = read_extern_global() + 1;
完成了x的赋值int extern_global = foo();
完成extern_global的赋值。那么这个BUG就出现了。
而对于2_1程序,其正常的原因是如下,我们照常推理其运行顺序
__libc_csu_init _GLOBAL__sub_I__Z3foov __static_initialization_and_destruction_0 _GLOBAL__sub_I__Z18read_extern_globalv __static_initialization_and_destruction_0
int extern_global = foo();
完成extern_global的赋值int x = read_extern_global() + 1;
完成了x的赋值。此时代码正常运行。
根据上面的信息,我们重点在于__libc_csu_init
调用CRT的顺序问题,我们可以对1_2程序打印断点。如下
# gdb ./1_2 (gdb) b _GLOBAL__sub_I__Z3foov (gdb) b _GLOBAL__sub_I__Z18read_extern_globalv (gdb) b __static_initialization_and_destruction_0
为了更好的判断extern_global的变量,这里挂上awatch,如下
(gdb) awatch extern_global Hardware access (read/write) watchpoint 4: extern_global
此时运行结果如下
(gdb) r Starting program: /root/asan/initialization_order/1_2 [Thread debugging using libthread_db enabled] Using host libthread_db library "/lib/aarch64-linux-gnu/libthread_db.so.1". Breakpoint 2, _GLOBAL__sub_I__Z18read_extern_globalv () at initialization_order_fiasco_1.cpp:10 10 } (gdb) c Continuing. Breakpoint 3, __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at initialization_order_fiasco_1.cpp:10 10 } (gdb) c Continuing. Hardware access (read/write) watchpoint 4: extern_global Value = 0 read_extern_global () at initialization_order_fiasco_1.cpp:5 5 } (gdb) c Continuing. Breakpoint 1, 0x00000000004006a8 in _GLOBAL__sub_I__Z3foov () at initialization_order_fiasco_2.cpp:2 2 int extern_global = foo(); (gdb) c Continuing. Breakpoint 3, __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at initialization_order_fiasco_2.cpp:2 2 int extern_global = foo(); (gdb) c Continuing. Hardware access (read/write) watchpoint 4: extern_global Old value = 0 New value = 2 0x000000000040068c in __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at initialization_order_fiasco_2.cpp:2 2 int extern_global = foo(); (gdb) c Continuing. 1 [Inferior 1 (process 137076) exited normally]
可以看到,出问题的程序1_2,在_GLOBAL__sub_I__Z18read_extern_globalv
中extern_global的值默认是0,由运行时默认赋值,然后在_GLOBAL__sub_I__Z3foov
中修改值为2,由代码初始化赋值,但是此时x的值已经在GLOBAL__sub_I__Z18read_extern_globalv
中取到为1了,所以问题出现了。
本文讨论了全局变量的顺序问题导致的BUG,此问题的原因主要在于gcc的链接过程中cpp的顺序会导致__libc_csu_init
运行_GLOBAL__sub_I_XXX
的运行顺序问题,从而导致问题的出现。
关于此问题的官方解释,有兴趣的可以翻阅,本文做简单的介绍:
"static initialization order problem":这里描述了多个静态类初始化如果存在依赖问题,则程序50%概率崩溃,解决办法是:"Construct On First Use"
"Why doesn’t the Construct On First Use Idiom use a static object instead of a static pointer":这里描述首次构造时应该使用对象而不是指针,因为指针会内存泄漏,但是如果使用对象,需要注意静态对象的销毁顺序问题。
https://isocpp.org/wiki/faq/ctors#static-init-order-on-first-use
"What is a technique to guarantee both static initialization and static deinitialization? ":静态对象构造和析构的技术要点
"How do I prevent the “static initialization order problem” for my static data members?":通过static Fred& x = X::x();
防止初始化问题
简单来说就说,如果非要使用静态全局对象,那么需要注意上述的每个要点,且都有利弊,否则不推荐频繁使用静态全局对象。