使用裸ld 链接器手动链接的一些小tips

1. 前言

在新手学习操作系统或者只针对于学习链接过程的朋友们来说,没有什么比手动把程序从预编译、编译、汇编、链接这一套camboo 自己手动打出来再直观的了,但是实际操作中往往会遇到一些奇奇怪怪的小问题,导致我们的小程序在整个过程中出现各种各样的问题,今天在这尽量写一些我遇到的问题、问题的起因以及解决办法,希望能够对学习路上的小伙伴有所帮助。

本人测试环境:64位,WSL(有一说一这玩意真挺香),Ubuntu-20.04

2. 一个基本的手动编译到链接的流程

在此先给出一个自己玩的简单的示例代码(注意是有a.c 和 b.c 俩文件的,不然也就没啥可链接的了哈哈):

/* a.c */
extern int shared;
int main()
    int a = 100;
    swap(&a, &shared);
/* b.c */
int shared = 1;
void swap(int* a, int* b)
    *a ^= *b ^= *a ^= *b;
}

上面这几行代码其实是我最开始玩的时候写的,本来的计划是分别把两个源文件编译成目标文件(.o),然后用ld链接起来,这不就达到最初的目标了波?但是现实往往残酷啊!接下来是我这样做一路上遇到的问题~

第一个坑:

$ gcc -c -fno-builtin a.c b.c
a.c: In function ‘main’:
a.c:15:5: warning: implicit declaration of function ‘swap’ [-Wimplicit-function-declaration]
   15 |     swap(&a, &shared);
      |     ^~~~
$ ld -e main a.o b.o -o ab
ld: a.o: in function `main':
a.c:(.text+0x74): undefined reference to `__stack_chk_fail'

编译之后那个warning不大重要,主要是我没在a.c里声明swap是外部函数,这个可以先忽略掉。主要问题是链接有一个未定义引用,导致链接失败了。__stack_chk_fail 这个东西其实是GCC进行栈相关检查的符号,它的定义在某个库里边,我们这里是想手动调用ld 链接器链接的,其实没有链接什么库文件进来,所以自然会报这个未定义引用啦,最简单的解决办法就是把这个默认的栈相关检查给关了:解决办法不是在链接过程中,而是在编译时加参数"-fno-stack-protector",强制gcc不进行栈检查,从而解决,如下:

$ gcc -c -fno-builtin -fno-stack-protector *.c
a.c: In function ‘main’:
a.c:15:5: warning: implicit declaration of function ‘swap’ [-Wimplicit-function-declaration]
   15 |     swap(&a, &shared);
      |     ^~~~
$ ld -e main a.o b.o -o ab

这次就不会再出现未定义引用的错啦。你以为这就结束了?当然没那么简单啦!

第二个坑:

当我们欢欢喜喜的运行上面链接出来的可执行文件的时候,就会发现:

$ ./ab
Segmentation fault (core dumped)

咋样,Segmentation fault的死亡凝视啊!这时候机智如我,先用gdb大法摸排一下子吧!

(gdb) r
Starting program: /home/pear/workspace/learn_linkersAndLoaders/static_linking/ab 
Program received signal SIGSEGV, Segmentation fault.
0x0000000000000001 in ?? ()
(gdb) bt
#0  0x0000000000000001 in ?? ()
#1  0x00007ffffffee197 in ?? ()
#2  0x0000000000000000 in ?? ()

这么老多问号看得我也是满脸黑人问号啊!!!

这个问题其实跟更深层次的问题有关,上帝是怎么创造世界的,哦不好意思,是程序是怎么进的main函数?

具体原理还是比较复杂的,在这篇文章里先不展开说明,实现细节可以忽略,只需要理解如下情况:

编程语言一般都需要语言库,语言库的一个重要作用就是实现对操作系统API的封装,我们平时跑个小程序啥的本质上是把代码编译成可执行文件,然后以一个进程的方式运行该可执行文件。

而对于一个进程的开始和结束其实就是依靠操作系统提供的API来实现的,而如何调用操作系统的API呢?就是通过刚刚提到的语言库了,在我的测试环境下,C的语言库就是大名鼎鼎的gnu搞的Glibc。

简单地说,平时用到的那种main函数形式的程序,其实是依靠Glibc来实现的,那种程序真正的入口并非main函数,而是该库里面的_start函数,由库负责初始化后调用main函数来执行程序的主体部分。

但是我们现在搞的这段代码,或者说我在玩的这一套手动编译链接的camboo中,我并不想依赖Glibc,是想尽量啥都不依赖。所以我们写这个main函数本质上没啥特殊的,跟平时我们常见的main函数不一样了,平时main函数那么特殊还是因为在依赖Glibc,Glibc指定入口就是main函数,所以它才特殊,现在咱不用Glibc了,入口其实是链接时候自己指定的,也就是上面出现过的ld的命令里“-e main”那个选项,所以这时候main函数其实不特殊了,为了展示这一点,后边我把它改个名字吧,叫niam吧(就是把main倒着写哈哈)!

说回刚才的话题,既然不用Glibc了,那原来main函数结束后Glibc帮我们结束进程的活也没人干了呀,所以刚才运行的时候进程一直没结束,一直在往后边地址上跑,结果跑到不该去的地方就自然被Segmentation fault给死亡凝视了呗。明白了原理其实解决方法也不难的,Glibc也不是神仙,它其实是通过调用操作系统的API “EXIT”实现的中断进程,我们现在只要自己搞一个函数实现同样的功能就好了,以下是加上这个功能的代码:

/* a.c */
extern int shared;
void exit()
    asm( "movq $66,%rdi \n\t"
         "movq $60,%rax \n\t"
         "syscall \n\t");
int niam()
    int a = 100;
    swap(&a, &shared);
    exit();
/* b.c */
int shared = 1;
void swap(int* a, int* b)
    *a ^= *b ^= *a ^= *b;
}

exit函数的实现是通过GCC内嵌汇编来写的,具体用法可以查手册。在此我就先不展开说它的原理了,如果有小伙伴感兴趣可以留言,我再详细补充一下子~

经过上面这样代码的修改,我们重新编译链接吧!

$ gcc -c -fno-builtin -fno-stack-protector *.c
a.c: In function ‘niam’:
a.c:15:5: warning: implicit declaration of function ‘swap’ [-Wimplicit-function-declaration]