shixudong@163.com
近期偶而发现在树莓派4B(64位bookworm)上gcc编译若干小程序,生成的二进制可执行文件都比64k大一点。对比了一下x86环境,同样的源码,x86环境下生成的可执行文件大都在16k左右。经网上搜索,发现上述现象与gcc使用的链接器(Linker)默认设置有关。
一、aarch64环境下编译小程序生成64k二进制文件
Aarch64支持4k、16k和64k页,aarch64平台上gcc默认链接器ld的参数max-page-size默认取值64k,实现了不同页大小环境下的二进制兼容。在binutils 2.39以前,RELRO尾部和common-page-size(默认值4k)对齐,由于RELRO使用mprotect实现只读保护,保护区域必须是系统页长(PAGESIZE)的整数倍,另一方面RELRO机制要求只能保护RELRO段本身,不允许超范围保护。在4k页面环境下,因RELRO尾部已对齐PAGESIZE边界,只读保护正常发挥作用。在非4k页面环境下,无法保证RELRO尾部对齐PAGESIZE边界,因此系统无法对越过PAGESIZE边界的RELRO尾部进行只读保护,当RELRO段长度较小不能跨越PAGESIZE边界时,相当于RELRO完全不起作用。
针对这一bug,binutils 2.39将RELRO尾部和max-page-size对齐,也就是说,始终对齐在各种页长(4k、16k或64k)的边界,解决了不同页大小环境下RELRO段的只读保护全覆盖问题,但在实现RELRO尾部对齐时采用了RELRO头部后移方式,头部后移的长度取决于max-page-size,且头部后移产生的间隙需要占用磁盘空间,所以导致编译生成的可执行文件增加了(max-page-size)-(common-page-size)=60k的无效数据。
为了给aarch64平台二进制可执行文件消肿,可在gcc编译时使用-z norelro取消RELRO段,缺点是.got得不到只读保护,降低了安全性。对于4k页面环境,也可以用-z max-page-size=4096,消除60k无效数据,缺点是可执行文件无法在非4k页面环境下运行,降低了兼容性。
由于lld采用了不同的PT_LOAD/RELRO实现(后文有展开),可以使用-fuse-ld=lld取代默认链接器ld来给aarch64平台可执行文件消肿,不足之处在于lld也将RELRO尾部和common-page-size(默认值4k)对齐,在非4k页面环境下,同样存在RELRO段的只读保护全覆盖问题。事实上,lld也曾试图将RELRO尾部和max-page-size(默认值64k)对齐以修复这一bug,然而由于RELRO关联的PT_LOAD尾部并未同步对齐max-page-size,导致这一修复引发了新问题。在非64k页面环境下,RELRO关联的PT_LOAD段mmap到内存空间时,只是根据该PT_LOAD段长度将其尾部对齐到系统页长PAGESIZE(假设为4k或16k),因此mapped区域往往小于64k,如RELRO尾部已对齐max-page-size,则mprotect只读保护区域为64k,将覆盖关联PT_LOAD段的unmapped区域,导致RELRO只读保护不成功,程序报错无法运行。
即使lld使用-z common-page-size=65536,希望实现不同页大小环境下RELRO段的只读保护全覆盖,基于相同的原因,在非64k页面环境下,程序同样报错无法运行。
lld18修复了RELRO和关联PT_LOAD段的尾部对齐同步问题,如果RELRO和关联PT_LOAD段尾部对齐max-page-size(默认值64k),可以实现不同页大小环境下RELRO段的只读保护全覆盖。lld在实现RELRO尾部对齐时采用了RELRO尾部添加padding标记的方式,虽然尾部padding标记的长度也取决于max-page-size,但其并不占用磁盘空间,所以-zmax-page-size取值不会影响可执行文件的大小。
然而,技术不是万能的,反而是公平的,lld在实现RELRO尾部对齐到max-page-size时,虽然尾部padding标记不占磁盘空间,不会增加可执行文件的大小,但程序加载时该尾部padding标记的长度同样需要占用内存空间,最多将会浪费(max-page-size)-PAGESIZE字节的内存空间(cat /proc/PID/maps)。与此相反,默认链接器ld在实现RELRO尾部对齐时采用RELRO头部后移方式,后移产生的间隙需要占用磁盘空间,增加了可执行文件的大小,但这些间隙在程序加载时不占用内存空间。
基于上述考虑,lld18最终并没有实现将RELRO尾部和max-page-size(默认值64k)对齐,而是继续采用RELRO尾部和common-page-size(默认值4k)对齐的方式,但可以使用-z common-page-size=65536,实现不同页大小环境下RELRO段的只读保护全覆盖。
Mold的PT_LOAD/RELRO实现和lld基本一致,因此也可以使用-fuse-ld=mold取代默认链接器ld来给aarch64平台可执行文件消肿(树莓派官方仓库安装的mold版本为v1.10.1,会产生无害告警信息,不影响使用。真实原因是64位树莓派使用三级页表,CONFIG_PGTABLE_LEVELS=3,虚拟地址空间只有39位,对应512G内存。而mold使用的mimalloc对于64位linux,首次总是在虚拟内存2TB处获取对齐32M的32M内存块,必然失败。然后不指定地址向内核申请64M内存块,并掐头去尾,最终得到对齐32M的32M内存块,所以说告警属于无害信息)。和lld相比,mold已将RELRO尾部和max-page-size(默认值64k)对齐,既能实现不同页大小环境下RELRO段的只读保护全覆盖,也不会增加可执行文件的大小,但同样会增加内存空间不必要的浪费。
经过上述消肿处理后,aarch64环境下编译小程序生成的可执行文件可以缩小到8k左右,远远小于默认的64k。
二、x86环境下编译小程序生成16k二进制文件
如前所述,小程序在aarch64环境下消肿后生成的可执行文件为8k左右,同样的源码,在x86环境下(debian+bookworm)对应的可执行文件却在16k左右。这是由于gcc默认链接器ld在非x86环境下默认使用-z noseparate-code,而在x86环境下默认使用-z separate-code。
非x86环境默认使用-z noseparate-code,产生两个PT_LOAD段(RE和RW),RE段包括elf头、代码(.text)和常量(.rodata),RW段包括.got和变量(.data和.bss)。考虑到常量(.rodata)放在RE段,具有执行权限,增加了攻击面,为此binutils 2.30增加了-z separate-code,可将常量从RE段中分拆出来,以提高程序安全性。binutils 2.31进一步将-z separate-code作为x86环境的默认选项。
自binutils 2.31起,x86环境默认使用-z separate-code,产生四个PT_LOAD段(R、RE、R和RW),第一个R段只包括elf头,RE段只包括代码(.text),第二个R段只包括常量(.rodata),显然separate-code将noseparate-code对应的RE段拆分为了三段(R、RE和R)。RW段则和先前保持一致,没有拆分。多出来的两个PT_LOAD段给可执行文件贡献了2*max-page-size=8k字节(x86环境下max-page-size默认取值4k),最终导致x86环境的可执行文件为16k左右(即aarch64环境下消肿后的8k左右+2*4k)。
binutils 2.43新引入的-Wl,--rosegment可将两个R段合并,最终产生三个PT_LOAD段(RE、R和RW),能使可执行文件再减少1个max-page-size字节。无论PT_LOAD段的数量是两个、三个还是四个,最后一个RW段内容总是不变,RELRO在程序加载时调用mprotect将其变更为R(.got)和RW(.data和.bss)两个段。
Lld默认情况下产生四个PT_LOAD段(R、RE、RW和RW),R段包括elf头和常量(.rodata),基本上和ld使用-Wl,--rosegment后产生的R段一致,RE只包括代码(.text),也和ld使用-z separate-code后产生的RE段一致。和ld不同之处在于,lld有两个RW段,第一个RW段包括.got,由RELRO在程序加载时调用mprotect变更为R段,第二个RW段包括变量(.data和.bss)。
Lld也有-Wl,--no-rosegment、-Wl,--rosegment(默认)、-z separate-code和-z noseparate-code(默认)参数,但这些参数的含义和ld完全不同。
Lld的-Wl,--no-rosegment可将R段和RE段合并为一个RE段,包括elf头、代码(.text)和常量(.rodata),基本等价于ld的-z noseparate-code生成的RE段,最终产生三个PT_LOAD段(RE、RW和RW)。
Lld的-z separate-code和-z noseparate-code不像ld那样影响PT_LOAD数量,而是通过不同的对齐方式影响磁盘空间占用和内存空间消耗,-z noseparate-code不受-z max-page-size取值影响,生成的可执行文件磁盘占用空间小,但运行时PT_LOAD头部内存空间有一定程度浪费。-z separate-code则正好相反,生成的可执行文件磁盘占用空间大(取决于-z max-page-size取值),但运行时不会浪费内存空间。
Mold的PT_LOAD/RELRO实现和lld基本一致,也用和lld同样的语义和默认选择使用上述参数,区别在于mold的RELRO尾部和max-page-size对齐,而lld的RELRO尾部和common-page-size对齐而已。但如前所述,lld/mold采用RELRO尾部添加padding标记的方式来实现RELRO尾部对齐,padding标记不占用磁盘空间,因此无论是对齐max-page-size,还是对齐common-page-size,都不会影响可执行文件大小。
Lld/mold默认使用-Wl,--rosegment和-z noseparate-code,在提高程序安全性的同时,减少了可执行文件的磁盘空间占用,并且不受-z max-page-size取值影响。因此如同aarch64环境一样,x86环境使用-fuse-ld=lld/mold取代默认链接器ld后,小程序生成的可执行文件同样可以缩小到8k左右。
三、Aarch64平台链接器参数max-page-size默认取值
顺便提一下,aarch64平台上链接器ld/lld/mold的参数max-page-size默认取值64k,使用-z max-page-size=4096生成的可执行文件无法在非4k页面环境下运行。通过分析链接器的PT_LOAD实现,更有助于理解这一结论。对于磁盘上的可执行文件,除了RELRO尾部需要对齐max-page-size外(lld的RELRO尾部对齐common-page-size),只要求PT_LOAD头部对齐max-page-size,PT_LOAD尾部没有对齐要求。但在程序加载并将PT_LOAD段mmap到内存空间时,PT_LOAD段头部和尾部还需要对齐系统页长PAGESIZE,头部根据偏移下对齐,尾部根据段长上对齐。
使用-z max-page-size=4096,可执行文件里的PT_LOAD段头部仅对齐到4k,在非4k页面环境下(即PAGESIZE为16k或64k),程序加载时,多个PT_LOAD段头部根据偏移下对齐到同一个16k或64k边界。显然,多个PT_LOAD段之间互相重叠,这些PT_LOAD段被mmap到同一块内存空间,先前mapped的PT_LOAD段将被后面mapped的PT_LOAD段覆盖,导致程序崩溃。
使用-z max-page-size=65536,可执行文件里的PT_LOAD段头部已对齐到64k,在非64k页面环境下(即PAGESIZE为4k或16k),程序加载时,多个PT_LOAD段根据各自头部偏移下对齐到PAGESIZE后被mmap到不同的内存区域,再根据PT_LOAD段长度将其尾部上对齐到PAGESIZE。每个PT_LOAD段之间都不会重叠,程序能顺利运行。
因此,为了实现aarch64平台不同页大小(4k、16k和64k)环境下可执行文件的二进制兼容,aarch64版链接器ld/lld/mold的参数max-page-size默认取值为64k。同理x86平台的PAGESIZE为4k,所以x86版链接器ld/lld/mold的参数max-page-size默认取值为4k。