ARTICLE · 992047
poll 源码分析(二):用户态调用流程
本文基于 2.43 版本的 glibc 源码,分析在 AArch64(ARM64)平台上,用户程序调用 poll() 后,在用户空间经历的完整调用链,直到执行 svc 0x0 陷入内核态为止。
1. 总览
在 ARM64 平台上,虽然应用程序调用的是 poll(),但最终陷入内核的系统调用其实是 ppoll()。整条用户态调用链如下:
poll(&fds, nfds, 5000)__poll(fds, nfds, timeout)SYSCALL_CANCEL(ppoll, ...)__syscall_cancel(...)__internal_syscall_cancel(...)// 快路径INTERNAL_SYSCALL_NCS_CALL(...)svc 0x0// 慢路径__syscall_cancel_arch(...)svc 0x0
可以看到,最终存在两条路径,但它们都结束在 svc 0x0 这条汇编指令。执行完这条指令后,用户进程就陷入了内核。这两条路径会在后面展开解释。
也许你已经注意到了,调用链里的很多函数和宏都带有 cancel 这个词。那它到底是什么意思呢?这涉及到线程取消机制。
pthread 允许一个线程请求去终结另一个线程(pthread_cancel())。但被取消的线程并不会立刻死掉。试想一下,如果它此刻正拿着锁、或开着文件,那突然死掉就是事故现场了。
POSIX 的设计是:先在目标线程身上标记,等它自己运行到下一个合适的地方后再体面退场(执行清理函数、释放资源)。这个合适的地方被称为取消点(cancellation point)。取消点主要就是 poll、read、sleep 这类可能长期阻塞的函数。
再试想一下,如果 poll 里没有取消点,且线程在调用 poll 后一直阻塞,那线程就会一直赖着不死。很明显这不是我们想要的结果。
因此这类函数在发起系统调用时,都要顺带检查一下取消标志,这就是调用链中带 cancel 字样的由来。
本文对线程取消机制只会点到即止,重点还是放在 poll 实现上,事实上我也没有深入研究过这个机制。
下面对整个调用链里的函数和宏逐个分析。
2. 第一步:从 poll 到 __poll
glibc 的 Linux 版 poll 实现位于 sysdeps/unix/sysv/linux/poll.c,完整代码如下:
int__poll (struct pollfd *fds, nfds_t nfds, int timeout){#ifdef __NR_pollreturn SYSCALL_CANCEL (poll, fds, nfds, timeout);#elsestruct timespec timeout_ts;struct timespec *timeout_ts_p = NULL;if (timeout >= 0){timeout_ts.tv_sec = timeout / 1000;timeout_ts.tv_nsec = (timeout % 1000) * 1000000;timeout_ts_p = &timeout_ts;}return SYSCALL_CANCEL (ppoll, fds, nfds, timeout_ts_p, NULL, 0);#endif}libc_hidden_def (__poll)weak_alias (__poll, poll)strong_alias (__poll, __libc_poll)
要理解这段代码,关键要搞清楚如下三个问题:
1. 应用程序调用的 poll 是怎么走到这个 __poll 上的?
2. 编译为什么走的是 else 分支?
3. 超时参数为什么要做转换?
下面逐个来分析:
2.1 poll 是怎么走到 __poll 上的?
先看应用程序这一侧。应用代码里的 poll() 编译后是一个未定义符号,链接时在 libc.so 的导出符号表里找到 poll;运行时的调用经过 PLT(Procedure Linkage Table,过程链接表)跳进 libc。
再看 libc 这一侧。先看第 21 行的 weak_alias()。 它定义在 include/libc-symbols.h,展开后是这样一句声明:
extern __typeof (__poll) poll __attribute__ ((weak, alias ("__poll")));__typeof (__poll):获取 __poll 函数的类型,相当于 extern int poll (struct pollfd *, nfds_t, int),只不过不用把原型再抄一遍。进一步展开后的完整声明如下:
externintpoll(struct pollfd *, nfds_t, int) __attribute__ ((weak, alias ("__poll")));这样看着顺眼多了。前半部分是个函数声明,后半部分则是 GCC 的属性描述,__attribute__ 是语法关键字,括号里的则是具体的属性。
alias ("__poll"):告诉编译器,poll 不是一个新函数,而是 __poll 的另一个名字。
weak:把 poll 标成弱符号。
弱符号是链接器的一条规则:同名符号相遇时,两个强符号会报重复定义的错误,一强一弱的话则默认选强的。
为什么要给 poll 这个待遇呢?因为 C 标准并没有把 poll 列为保留字,应用程序完全可以自己写一个叫 poll 的函数。libc 把 API 名做成弱符号,就是为了防止这种情况,此时链接器会选择用户自定义的 poll,而不会使用库里的版本。
需要特别说明的是:上面说的弱符号规则都是在静态链接(libc.a)的场景下。动态链接时不会有名字冲突的问题,因为应用程序里的定义总是优先于共享库(libc.so)里的。
再看第 20 行的 libc_hidden_def()。简单来说,它也创建了一个别名,叫 __GI___poll,但只给 libc 内部调用。具体细节就不展开了,感兴趣的道友可以进一步研究。
最后一行 strong_alias() 再造一个内部名 __libc_poll,未在版本脚本中导出,属内部/历史用途。了解即可。
总结一下:
符号 | 由谁生成 | 性质 | 谁来用 |
__poll | 函数定义本身 | 强符号,导出 | 真身 |
weak_alias | 弱符号,导出 | 应用程序 | |
__GI___poll | libc_hidden_def | 隐藏,不导出 | libc 内部调用 |
__libc_poll | strong_alias | 强符号,未导出 | 内部/历史用途 |
所以,应用调用 poll 跑到 __poll,在运行时其实什么都没发生,因为它们本来就是同一个地址,只是链接期的符号解析选中了它。
2.2 为什么走 else 分支?
很明显,这个问题的关键在于确认是否定义了 __NR_poll。它的定义由各架构生成的系统调用号总表 arch-syscall.h 决定。
ARM64 的总表是 sysdeps/unix/sysv/linux/aarch64/arch-syscall.h 这个文件(共 325 个调用号),里面并没有定义 __NR_poll,只能找到:
#define __NR_ppoll 73而 x86_64 的同名文件里有:
#define __NR_poll 732 位 ARM 里则有:
#define __NR_poll 168所以这两个平台上,它们编译时走的是 #ifdef 分支,真正发起 poll 系统调用。ARM64 上 __NR_poll 未定义,预处理后只剩 #else 分支,最后 poll() 从源头上就被编译成了一次 ppoll() 调用。
ARM64 平台采用内核 asm-generic 统一系统调用表。这张新表做了一次大的优化:凡是功能已被新调用完全覆盖的老调用,一律不再提供调用号。
所以,ARM64 上,没有 poll 只有 ppoll,没有 select 只有 pselect6,没有 open 只有 openat,没有 epoll_wait 只有 epoll_pwait。老接口统统由 libc 库包装成新调用,就像本文的 poll。这也是为什么 ARM64 平台上的系统调用号足足比 ARM32 平台上的少了 100 个的原因(325 vs 425)。
2.3 超时参数为什么要做转换?
要做转换的原因很简单,因为 ppoll 的定义就是如此,哈哈。从 poll 的 int 型变成 ppoll 的 struct timespec 结构体,最明显的一点改善就是超时精度提升了。
在接口层面,理论上精度可以达到纳秒级,这对需要更细粒度定时的场景来说无疑是雪中送炭了。当然,实际精度还要受系统时钟粒度以及调度延迟等因素的限制,但 ppoll 在语义上是支持纳秒级精度了。
poll 的 timeout 是 int 毫秒,而 ppoll 收的是 struct timespec *(秒 + 纳秒):
timeout >= 0:拆成 timeout / 1000 秒 + (timeout % 1000) * 1000000 纳秒;
timeout < 0:poll 语义中表示无限等待,根据代码逻辑,timeout_ts_p 指针将保持为 NULL,而 NULL 在 ppoll 语义下恰好也是无限等待。
2.4 既生瑜何生亮?
阿瑜说,不是,poll 说,既然都有我了,为什么还要搞出个 ppoll?关于这个问题的答案,这里先卖个关子,等到后面分析 sys_ppoll() 函数时再来详细说明。
这个问题和 ppoll 的最后两个参数密切相关,也就是 sigmask 和 sigsetsize。因为用户只需要 poll 的功能,不需要处理信号掩码,所以才将这两个参数设置为了 NULL 和 0。
3. 第二步:SYSCALL_CANCEL 宏展开
__poll() 最后调用的是 SYSCALL_CANCEL() 宏,其定义在 sysdeps/unix/sysdep.h。事实上,很多系统调用都会用到这个宏,这样它就面临一个小难题:不同的系统调用,参数个数不等,而 C 的宏又没法直接按参数个数重载。glibc 用一组经典的预处理巧妙地解决了这个问题。
下面这些宏全部定义在 sysdeps/unix/sysdep.h 头文件里。首先是SYSCALL_CANCEL():
# define SYSCALL_CANCEL(...) \__SYSCALL_CANCEL_CALL (__VA_ARGS__)#define __SYSCALL_CANCEL_CALL(...) \__SYSCALL_CANCEL_DISP (__SYSCALL_CANCEL, __VA_ARGS__)
__SYSCALL_CANCEL_DISP (__SYSCALL_CANCEL, ppoll, fds, nfds, timeout_ts_p, NULL, 0);继续看 __SYSCALL_CANCEL_DISP () 的定义:
#define __SYSCALL_CANCEL_DISP(b, ...) \__SYSCALL_CANCEL_CONCAT (b, __SYSCALL_CANCEL_NARGS (__VA_ARGS__)) (__VA_ARGS__)
这里开始有点懵了,不着急,看看 __SYSCALL_CANCEL_NARGS() 的定义:
#define __SYSCALL_CANCEL_NARGS(...) \__SYSCALL_CANCEL_NARGS_X (__VA_ARGS__,7,6,5,4,3,2,1,0,)#define __SYSCALL_CANCEL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
__SYSCALL_CANCEL_NARGS_X (ppoll,fds,nfds,timeout_ts_p,NULL,0,7,6,5,4,3,2,1,0,)第 9 个参数是 5,所以 __SYSCALL_CANCEL_NARGS (__VA_ARGS__) 的值是 5,这样上面的 __SYSCALL_CANCEL_DISP () 就简化成了:
#define __SYSCALL_CANCEL_DISP(b, ...) \__SYSCALL_CANCEL_CONCAT (b, 5) (__VA_ARGS__)
那么原来第 17 行代码展开后就是:
__SYSCALL_CANCEL_CONCAT (__SYSCALL_CANCEL, 5) (__VA_ARGS__)继续看 __SYSCALL_CANCEL_CONCAT() 的定义:
#define __SYSCALL_CANCEL_CONCAT_X(a,b) a##b#define __SYSCALL_CANCEL_CONCAT(a,b) __SYSCALL_CANCEL_CONCAT_X (a, b)
很简单,就是把两个参数粘起来,所以第 17 行代码进一步变成:
__SYSCALL_CANCEL5 (__VA_ARGS__)替换 __VA_ARGS__ 后:
__SYSCALL_CANCEL5 (ppoll,fds,nfds,timeout_ts_p,NULL,0)终于展开完了,__SYSCALL_CANCEL5() 的定义如下:
#define __SYSCALL_CANCEL5(name, a1, a2, a3, a4, a5) \__syscall_cancel (__SSC (a1), __SSC (a2), __SSC (a3), __SSC(a4), \__SSC (a5), 0, __SYSCALL_CANCEL7_ARG __NR_##name)
终于看到最终要调用的函数了,没错,就是 __syscall_cancel()。
先停一停。我想你现在一定比较懵,尤其是 9 和 5 那个部分。我建议你再回头捋一遍这个宏替换过程。捋完后你很可能也会有以下疑问:
这个设计确实比较巧妙,不同的参数个数,最终会走到不同的 __SYSCALL_CANCELx() 宏,然后对应不同的函数传参。但是,如果参数个数超过了 9 个呢?这样 n 就不会落在 7 - 0 之间了,而是取到了函数的某个参数。这不就出错了吗?
举个例子。假设第 17 行的代码是:
return SYSCALL_CANCEL(ppoll, fds, nfds, timeout_ts_p, NULL, 0, "ab", "cd", "ef");那 __SYSCALL_CANCEL_NARGS_X() 的列表将变成:
(ppoll,fds,nfds,timeout_ts_p,NULL,0,"ab","cd","ef",7,6,5,4,3,2,1,0,)这样,n,也就是第 9 个参数,就变成 "ef" 了!
是的,的确如此。但是,没有关系,因为此时经过 __SYSCALL_CANCEL_CONCAT() 粘贴后生成的新名字是个非法名字,所以编译期间就会报错。
你也许会追问,那如果第 9 个参数不是字符串,恰好是个数字呢?这样生成的新名字就很有可能是个合法名字了。比如,假设第 9 个参数是 3,那新名字将是 __SYSCALL_CANCEL3,刚好有这个定义。
不要慌,此时编译还是会报错。因为传给 __SYSCALL_CANCEL3 的参数个数将会是 9 个:
__SYSCALL_CANCEL3(ppoll, fds, nfds, timeout_ts_p, NULL, 0, "ab", "cd", "ef")而 __SYSCALL_CANCEL3 的定义是:
#define __SYSCALL_CANCEL3(name, a1, a2, a3) \__syscall_cancel (__SSC (a1), __SSC (a2), __SSC (a3), 0, 0, 0, \__SYSCALL_CANCEL7_ARG __NR_##name)
装不下 9 个参数,所以编译还是会报错。
这就是这个宏的巧妙之处,无论你怎么定义,只要非法,编译阶段一定能暴露出来,不可能流到执行阶段。
事实上,Linux 关于系统调用有个约定:所有系统调用最多只能有 6 个参数。不过,mips 平台上允许最多 7 个参数,这也是为什么宏定义里是 7,6,5,4,3,2,1,0 而不是 6,5,4,3,2,1,0。7 是给 mips 32 位架构拆分 64 位参数留的余量。感兴趣的道友可以再深入研究,我就不班门弄斧了。
OK,继续分析扒开层层外衣后的 __syscall_cancel() 。为方便阅读,放到这里:
#define __SYSCALL_CANCEL5(name, a1, a2, a3, a4, a5) \__syscall_cancel (__SSC (a1), __SSC (a2), __SSC (a3), __SSC(a4), \__SSC (a5), 0, __SYSCALL_CANCEL7_ARG __NR_##name)
__SSC 是个类型转换宏,把参数都转成 long,因为这些参数都将会放到寄存器里,后面再详细说明。__NR_##name 拼出 __NR_ppoll,预处理期间将直接替换为常量 73。__SYSCALL_CANCEL7_ARG 只在 mips 平台上有效,ARM64 上它为空。
于是乎,最终得到一个普通 C 函数调用:
__syscall_cancel ((long int)(fds), /* a1 */(long int)(nfds), /* a2 */(long int)(timeout_ts_p), /* a3 */(long int)(NULL), /* a4: sigmask */(long int)(0), /* a5: sigsetsize */0, /* 补齐的占位 */73); /* nr: __NR_ppoll */
不足 6 个的系统调用参数用 0 补齐,所以倒数第二个参数给的是 0。系统调用号 73 被放在了参数列表的最后。请记住这个位置,后面它还要挪两次窝。
libc.so 与 ld.so
其实,SYSCALL_CANCEL() 的定义处有一个编译期分支,上面的分析默认走的是 else 分支:
#if IS_IN (rtld)/* The loader does not need to handle thread cancellation,use direct syscall instead. */# define SYSCALL_CANCEL(...) \INLINE_SYSCALL_CALL (__VA_ARGS__)#else# define SYSCALL_CANCEL(...) \__SYSCALL_CANCEL_CALL (__VA_ARGS__)#endif
很明显,IS_IN (rtld) 控制着分支选择。要理解这个宏,得先搞清楚 libc.so 和 ld.so 是两个什么东西。
libc.so 是 C 库本体,poll、malloc、printf 等函数都在里面,是应用程序的一个普通依赖库。
ld.so 是动态链接器,可以粗暴地理解为每个动态链接程序的开机引导程序。ARM64 上叫 ld-linux-aarch64.so.1,源码里叫 rtld,run-time loader。
可执行文件的 ELF 头里有一栏 PT_INTERP,就写着它的路径:
$ readelf -l a.out | grep interpreter[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
内核执行一个程序时并不是直接跳到 main,而是先把 ld.so 装进内存并跳进去。ld.so 负责:找出程序依赖的所有共享库(libc.so 等)-> 逐个映射进内存 -> 把符号地址填进各处的跳转表 -> 跑各库的初始化函数 -> 最后才跳到程序入口。
之后运行期间它也不闲着,dlopen 加载新库、PLT 首次调用时的地址解析,也都由它负责处理。
这里有个鸡生蛋的问题。ld.so 干这些活时,比如打开库文件、映射内存、处理路径字符串等,libc.so 都还没加载好,一个函数都不能用。所以,ld.so 必须自带一小撮的 libc 功能,比如打开库文件就需要 open,内存映射就需要 mmap,等等。
所以,glibc 里的同一份源文件,编进 libc.so 时 IS_IN (libc) 为真,编进 ld.so 时则 IS_IN (rtld) 为真,源码就是靠这个宏来判断它当前在哪个库里。注意,同一份源文件完全有可能既编译进 libc.so,也编译进 ld.so。比如,定义 open 函数的源文件。
对于编译链接感兴趣的道友可以自行另查资料深究,我这里就不展开了。
回到上面的宏代码。注释说的很清楚,如果 IS_IN (rtld) 为真,那么加载器(ld.so)不需要处理线程取消,直接走不带取消检查的 INLINE_SYSCALL_CALL()。
可是,为什么呢?为什么加载器里的函数就不需要有取消点呢?
加载器在启动阶段和运行期都会修改全进程共用的状态。启动阶段只有主线程,无人能发起线程发取消请求,取消也就无从谈起。
运行期则不同,任何线程都可能进入加载器,比如首次调用某函数时的地址解析、dlopen 等,取消请求随时可能到来。
假设允许线程死在加载器半途,会出两件事。
一是锁:加载器改公共状态前会先加一把全进程的锁,防止多个线程同时改。线程带着锁死了,锁永远释放不了,以后任何线程再进加载器都会死锁。
二是半成品状态:库映射了一半、地址修正了一半、库列表插了一半。这些不是死掉线程的私有数据,而是所有线程共用的,其它线程接着用就会出错。
两条中任何一条都足以毁掉整个进程。
反过来,不打断也没有损失。
取消点的存在是为了让 poll 这类可能无限期阻塞的调用能被叫醒,而加载器里的系统调用只是打开文件、读几百字节的文件头部、做内存映射,很快就做完,不需要等任何外部事件。
取消请求最多推迟这一小会儿,加载器一收工,完全不耽误线程走到下一个真正的取消点。
INLINE_SYSCALL_CALL() 宏最终会走到如下宏函数,定义在 sysdeps/unix/sysv/linux/aarch64/sysdep.h 头文件中:
# define INTERNAL_SYSCALL_RAW(name, nr, args...) \({ long _sys_result; \{ \LOAD_ARGS_##nr (args) \register long _x8 asm ("x8") = (name); \asm volatile ("svc 0 // syscall " # name \: "=r" (_x0) : "r"(_x8) ASM_ARGS_##nr : "memory"); \_sys_result = _x0; \} \_sys_result; })
可以看到,它也是通过 svc 0 这条汇编指令陷入内核态。后面分析快路径时再来详细拆解这段代码。
那么,poll 到底有没有编译进 ld.so 呢?这取决于 ld.so 是否需要 poll 函数。通用的 ld.so 是不包含 poll 的,但会有 open/read/mmap 这几个函数。所以,对于 poll 来说,总是走 else 分支。
当然,如果你想做实验,完全可以把 poll 加到 ld.so 里去,此时的 poll 就会走 if 分支,直接发起系统调用。这部分细节涉及到 ld.so 的生成过程,我也没研究过,而且越来越偏离主题了。
4. 第三步:__syscall_cancel()
继续往下分析。__syscall_cancel() 这个函数定义在 nptl/cancellation.c:
/* Called by the SYSCALL_CANCEL macro, check for cancellation and return thesyscall expected success value (usually 0) or, in case of failure, -1 andsets errno to syscall return value. */long int__syscall_cancel (__syscall_arg_t a1, __syscall_arg_t a2,__syscall_arg_t a3, __syscall_arg_t a4,__syscall_arg_t a5, __syscall_arg_t a6,__syscall_arg_t nr){long int r = __internal_syscall_cancel (a1, a2, a3, a4, a5, a6, nr);return __glibc_unlikely (INTERNAL_SYSCALL_ERROR_P (r))? SYSCALL_ERROR_LABEL (INTERNAL_SYSCALL_ERRNO (r)): r;}
函数看起来很简单:
1. 进一步调用 __internal_syscall_cancel() 函数。
2. 根据宏名,return 的时候似乎在做返回值的转换处理。
注释也给我们提供了很多有用的信息:
1. 这个函数由 SYSCALL_CANCEL 宏调用。这符合我们前面的分析,我们就是从 SYSCALL_CANCEL 宏一路追踪到此。
2. 函数会去检查取消点。
3. 如果系统调用成功,通常返回成功值 0。
4. 如果系统调用失败,那么返回 -1,并且会设置 errno。
函数体很短,暂时先不分析 __internal_syscall_cancel(),这个函数留到后面专门分析。我们现在重点分析 return 那条语句。
4.1 内核的返回值约定
系统调用从内核返回时,只有一个寄存器(ARM64 上是 x0)携带结果,并没有单独的成功还是失败的标志位。
Linux 的约定是:
- 成功:返回真正的结果,比如 poll 返回就绪的 fd 个数(0、1、2…);
- 失败:返回负的错误码,比如 -EINTR(-4)、-EINVAL(-22)。
那么问题来了:有些调用成功时的返回值转成有符号数后也是负的,比如 mmap 返回的高位地址,怎么和错误码区分呢?
答案是靠第二条约定:错误码最大只到 4095,对应内核源码里的 MAX_ERRNO。因此只有落在 [-4095, -1] 这个区间的返回值才是有效的错误码,同时内核保证任何系统调用的合法返回结果都不会落进这个区间。
4.2 INTERNAL_SYSCALL_ERROR_P
return 语句涉及到三个宏,先从第一个开始。INTERNAL_SYSCALL_ERROR_P() 定义在 sysdeps/unix/sysv/linux/sysdep.h 头文件中,非常简单:
#define INTERNAL_SYSCALL_ERROR_P(val) \((unsigned long int) (val) > -4096UL)
它就干一件事:判断 val 是否在 [-4095, -1] 这个区间。
原理:把所有的 64 位整数当成无符号数看待,[-4095, -1] 恰好就是最大的那 4095 个数。
以 -EINTR 为例:
- -4 按无符号看是 0xFFFFFFFFFFFFFFFC
- -4096UL 是 0xFFFFFFFFFFFFF000
- 前者 > 后者,宏为真
而正常返回值(如 0、1、2 这类就绪个数)强转成 unsigned long 后仍是小数字,比较不成立。
所以,一行比较代码完成区间判断。这样,return 语句的逻辑就清晰了:
- 如果内核返回的值在 [-4095, -1] 这个区间,那么进一步处理。
- 否则原样返回给上面的应用程序。
4.3 SYSCALL_ERROR_LABEL
下面继续分析错误码是如何处理的。为方便阅读,把 return 语句简化一下:
return SYSCALL_ERROR_LABEL (INTERNAL_SYSCALL_ERRNO (r));这两个宏同样定义在 sysdeps/unix/sysv/linux/sysdep.h 头文件里:
#define INTERNAL_SYSCALL_ERRNO(val) (-(val))# define SYSCALL_ERROR_LABEL(sc_err) \({ \__set_errno (sc_err); \-1L; \})
括号里面的处理非常简单,只是给返回值 r 取反而已。
还是举个具体的例子。假设内核返回了 -EINVAL(-22),取反后就是 22。那么 return 语句就变成了:
return SYSCALL_ERROR_LABEL (22);进一步展开:
return { __set_errno (22); -1L; }根据名字也能猜到,这是把 22 赋值给 errno,然后统一返回 -1.
系统调用失败返回 -1 并设置 errno 这条耳熟能详的约定,就是在这里处理的。内核只负责返回负数错误码,它并不知道 errno 的存在。
4.4 errno 是什么?
熟悉 Linux 系统编程的道友对 errno 这个变量应该不会感到陌生。在解释它之前,得先介绍下线程局部存储(TLS,Thread-Local Storage)这个东西。
本质上,TLS 就是一块内存空间。每个线程创建时都会分到一块属于自己的 TLS 内存,这块内存专门用来存放线程局部变量。所有的线程局部变量在这块内存里各占一个编译期(动态加载的库略复杂,此处不展开)就定好的位置(偏移量)。
那什么是线程局部变量呢?
如果一个变量使用了 __thread 来声明,比如:
__thread int x;那么 x 就是一个线程局部变量。全进程将只有一个叫 x 的变量名,但每个线程都会有一份 x 的实体,x 就保存在每个线程的 TLS 里。
很明显,既然每个线程都有各自的变量实体,那么它们之间是互不干扰的。线程 A 修改自己的 x,不会影响其它线程各自的那份 x。这和内核里的 per-CPU 变量非常像。
提一嘴,__thread 是 GCC 的扩展关键字,并不是 C 标准的一部分。
对比一下各个变量之间的区别,方便加深对 `__thread` 变量的理解:
函数内局部变量 | 线程局部变量 | ||
有几份 | 整个进程 1 份 | 每次函数调用 1 份 | 每个线程 1 份 |
活多久 | 与进程同寿 | 函数返回即消失 | 与线程同生共死 |
怎么声明 | int x; | 函数里 int x; | __thread int x; |
那么问题来了,怎么访问 __thread 变量呢?
ARM64 专门留了一个寄存器 TPIDR_EL0 用来指向线程控制块的边界,有了这个地址,我们就能得到 TLS 的基地址,因为它俩之间的偏移量是固定的。
线程切换时内核会把这个寄存器的值换成下一个线程的线程控制块的边界。关于线程控制块,下一小节再来详细解释。
于是,通过 TPIDR_EL0 寄存器 + 固定偏移量,我们就能拿到 TLS 的基地址。再加上每个 __thread 变量在编译期就确定下来的偏移量,就能访问到当前线程的 TLS 里的每一个 __thread 变量了。比如 x,当然也包括我们的主角 errno。
glibc 里的 errno 只有一个变量,但应用程序和 libc 看到的 errno 并不一样。
先看变量本身的定义。源码在 csu/errno.c:
__thread int errno;extern __thread int __libc_errno __attribute__ ((alias ("errno")))attribute_hidden;
第一行就是 errno 的真身,一个 __thread 变量,每线程一份。
第二行给它起了个只供 libc 内部使用的别名 __libc_errno(alias 加隐藏的套路,和 2.1 节中的 __GI___poll 如出一辙)。
再看应用程序看到的样子。include 的头文件是 <errno.h>,源码在 stdlib/errno.h:
extern int *__errno_location (void);#define errno (*__errno_location ())
应用程序写的 errno 其实是个宏:先调用 __errno_location() 拿到地址,再解引用。
这个函数只有一行,源码在 csu/errno-loc.c:
int *__errno_location (void){return &errno;}
好繁琐。为什么要绕一个函数?让应用直接访问那个 __thread 变量不行吗?
无他,为了分层。应用程序只依赖 __errno_location 这个稳定的函数接口,libc 内部怎么存 errno 可以灵活设计,应用不用重新编译。事实上,历史上曾把 errno 放在了线程结构体里。
最后是 libc 内部看到的样子。源码在 include/errno.h:
# define errno __libc_errnoextern __thread int errno attribute_tls_model_ie;
展开后:
extern __thread int __libc_errno attribute_tls_model_ie;正是上面提到的那个内部别名 __libc_errno。
attribute_tls_model_ie 是在告诉编译器:用这种最快的直接访问方式。4.3 节的 __set_errno (val) 展开为 __libc_errno = (val),走的正是这条路。
5. 第四步:__internal_syscall_cancel()
现在来分析第 4 节提到的 __internal_syscall_cancel() 函数。它同样定义在 nptl/cancellation.c,简化后的代码如下:
long int__internal_syscall_cancel (__syscall_arg_t a1, __syscall_arg_t a2,__syscall_arg_t a3, __syscall_arg_t a4,__syscall_arg_t a5, __syscall_arg_t a6,__SYSCALL_CANCEL7_ARG_DEF__syscall_arg_t nr){long int result;struct pthread *pd = THREAD_SELF;int ch = atomic_load_relaxed (&pd->cancelhandling);if (SINGLE_THREAD_P || !cancel_enabled (ch) || cancel_exiting (ch))return INTERNAL_SYSCALL_NCS_CALL (nr, a1, a2, a3, a4, a5, a6__SYSCALL_CANCEL7_ARCH_ARG7);result = __syscall_cancel_arch (&pd->cancelhandling, nr, a1, a2, a3, a4, a5,a6 __SYSCALL_CANCEL7_ARCH_ARG7);ch = atomic_load_relaxed (&pd->cancelhandling);if (result == -EINTR && cancel_enabled_and_canceled (ch))__syscall_do_cancel ();return result;}
形参列表里的 __SYSCALL_CANCEL7_ARG_DEF,以及函数体里的两处 __SYSCALL_CANCEL7_ARCH_ARG7,是给第 7 个参数预留的位置。前面说过,在 ARM64 上展开为空,所以直接忽略即可。
函数体可以分为三段:
1. 拿到当前线程的描述符 pd,然后读出它的取消标志 pd->cancelhandling。
2. if 语句:满足三个条件之一就走快路径,直接发起系统调用并返回。三个条件分别是:进程只有一个线程(SINGLE_THREAD_P)、本线程禁用了取消(!cancel_enabled)、本线程正在退出(cancel_exiting)。这三种情况下都不需要在此处响应取消。
3. 剩余部分:慢路径。通过架构相关的 __syscall_cancel_arch() 发起系统调用,完成后再检查一次是否需要取消。
所谓快路径,就是没有线程取消点,直奔主题,发起系统调用。慢路径则需要检查取消点,如果条件满足,线程就要退出。
下面来逐段分析。
5.1 THREAD_SELF
struct pthread 定义在 nptl/descr.h,源码注释叫它线程描述符(thread descriptor)。这个结构体成员很多,就不贴出来了。
线程描述符通过 THREAD_SELF 获取。它是个宏,ARM64 上定义在 sysdeps/aarch64/nptl/tls.h:
# define THREAD_SELF \((struct pthread *) __builtin_thread_pointer () - 1)
很明显,这个宏本质上是一个 struct pthread 指针。THREAD_SELF 和内核的 current 很像。
这个宏有两个点要搞清楚:__builtin_thread_pointer () 是什么,以及为什么要减 1。
逐个来看。
__builtin_thread_pointer () 是什么
这个函数并不属于 libc,而是 GCC 编译器的内建函数,用来返回当前线程的线程指针。在 ARM64 平台上,也就是 4.4 节说的寄存器 TPIDR_EL0 的值。这个函数被编译成一条指令:
mrs x0, tpidr_el0mrs 的意思是 Move Register from System register,把系统寄存器(tpidr_el0)的值读到通用寄存器(x0)里。这涉及到 ARM64 的寄存器背景知识了,可以查查资料,或者问问 AI。
注意,平台不同,这个内建函数被编译成的汇编指令也会不同。
为什么要减 1
这要看每个线程那块内存的布局:
低地址 高地址┌──────────────────────┬────────────┬─────────┐│ struct pthread │ tcbhead_t │ TLS ││ thread descriptor │ TCB │ errno │└──────────────────────┴────────────┴─────────┘↑TPIDR_EL0(线程指针)指向这里
线程指针指向的是中间那个 16 字节的 tcbhead_t,glibc 把它叫 TCB(线程控制块);struct pthread 紧挨着放在它前面,__thread 变量区放在它后面。
所以,从线程指针出发,按 struct pthread 的大小往前退一格,正好落到线程描述符的开头。这就是为什么要减 1 的原因。
另外,从这个布局也可以得知,TLS 基地址和 TPIDR_EL0 之间的偏移量是 16 个字节。
5.2 快路径
当满足前面说到的三个条件中的任何一个时,就走快路径。快路径的实现用的是 INTERNAL_SYSCALL_NCS_CALL() 这个宏,它定义在 sysdeps/unix/sysdep.h。
宏名里的 NCS 是 Non-Constant Syscall number 的缩写,非常量系统调用号。第 3 节里系统调用号 __NR_ppoll 是编译期常量 73,而到了这里它已经变成了普通变量 nr,所以要用 NCS 这一族宏。
它的展开套路和第 3 节的情况完全一样,就不再详细分析了。
最终到 INTERNAL_SYSCALL_RAW(),这是快路径真正发起系统调用的地方:
# define INTERNAL_SYSCALL_RAW(name, nr, args...) \({ long _sys_result; \{ \LOAD_ARGS_##nr (args) \register long _x8 asm ("x8") = (name); \asm volatile ("svc 0 // syscall " # name \: "=r" (_x0) : "r"(_x8) ASM_ARGS_##nr : "memory"); \_sys_result = _x0; \} \_sys_result; })
是不是似曾相识?没错,在第 3 节它也出现过。展开聊聊:
1. LOAD_ARGS_##nr (args):nr 是 6,拼出 LOAD_ARGS_6,它把 6 个参数依次绑定到寄存器变量 _x0 到 _x5 上。register long _x5 asm ("x5") 这种写法,就是告诉编译器:这个变量必须放在 x5 寄存器里。
2. register long _x8 asm ("x8") = (name):系统调用号放进 x8。是的,必须是 x8 寄存器。
3. asm volatile ("svc 0" ...):内联汇编,就一条 svc 指令。
关于这条内联汇编语句,简单解释下:
第一个冒号后面是输出:_x0,接收返回值;
第二个冒号后面是输入:_x8 和 6 个参数寄存器,ASM_ARGS_6 展开后为 "r"(_x0) ... "r"(_x5);
第三个冒号后面的 "memory",是告诉编译器这条指令可能读写任意内存,前后的内存访问不要乱序或省略。
4. 从 _x0 取出返回值,然后返回它给上一级调用者。
这里出现的寄存器约定,如调用号 x8、参数 x0 到 x5、返回值 x0,在第 6 节的汇编里还会再次见到。
对内联汇编的语法感兴趣的道友,请自行查阅其它资料研究。
5.3 慢路径
慢路径对应这四行代码:
result = __syscall_cancel_arch (&pd->cancelhandling, nr, a1, a2, a3, a4, a5, a6);ch = atomic_load_relaxed (&pd->cancelhandling);if (result == -EINTR && cancel_enabled_and_canceled (ch))__syscall_do_cancel ();
第一行调用架构相关的 __syscall_cancel_arch() 来发起系统调用。注意,参数顺序变了。第一个参数是取消标志的地址,系统调用号 nr 则被挪到了第二个参数。这个顺序要留意,后面要考。
后三行是取消检查,也是慢路径比快路径多出来的内容。
这里重新又读一次取消标志,是为了保证读到最新的值,因为刚才在系统调用期间它完全有可能已经被修改了。
如果系统调用是被信号打断的(result 为 -EINTR),而且本线程确实处于"取消已启用、且已被请求取消"的状态,就调用 __syscall_do_cancel() 结束本线程。这个函数不会返回,线程在此退场,poll 的调用者永远拿不到返回值。
为什么只认 -EINTR 呢?因为 -EINTR 表示系统调用什么都没干就被打断了,此时取消线程不会丢任何东西。如果系统调用已经做了事,比如 poll 已经报告了就绪的 fd,内核就不会返回 -EINTR,这里也就不取消,让结果正常返回给调用者。取消请求留到下一个取消点再处理。
其余情况,比如正常完成、超时、或真正的错误,则直接 return result,回到第 4 节做错误码转换。
至此,__internal_syscall_cancel() 分析完毕。最后剩下的 __syscall_cancel_arch() 是纯汇编实现,下一节继续。
6. 第五步:__syscall_cancel_arch()
接着奏乐接着舞。ARM64 平台上对该函数的实现在 sysdeps/unix/sysv/linux/aarch64/syscall_cancel.S:
ENTRY (__syscall_cancel_arch).globl __syscall_cancel_arch_start__syscall_cancel_arch_start:ldr w0, [x0]tbnz w0, TCB_CANCELED_BIT, 1fmov x8, x1mov x0, x2mov x1, x3mov x2, x4mov x3, x5mov x4, x6mov x5, x7svc 0x0.globl __syscall_cancel_arch_end__syscall_cancel_arch_end:ret1:b __syscall_do_cancelEND (__syscall_cancel_arch)
等等,函数传进来的参数呢?
5.3 节中调用这个函数的代码是:
__syscall_cancel_arch (&pd->cancelhandling, nr, a1, a2, a3, a4, a5, a6);但是函数体里一个变量名都没看到。难道一个变量都没用?要把这个问题回答清楚,得先解释下 ABI。
6.1 函数调用约定
ABI(Application Binary Interface,应用二进制接口)是一组机器码层面的约定。比如:参数放进哪些寄存器、返回值放在哪、栈怎么用、结构体怎么排布。
如果说 API 约定的是代码怎么写,那 ABI 约定的就是代码编译成二进制后怎么接头。只要双方遵守同一份 ABI,不同编译器、不同语言编出的代码就能互相调用。
函数之间怎么互相调用,遵守的是函数调用约定。ARM64 的函数调用约定叫 AAPCS64(Procedure Call Standard for the Arm 64-bit Architecture,ARM 官方文档)。与本节相关的只有两条:
- 前 8 个整型或指针参数,依次放在 x0 到 x7;
- 返回值放在 x0。
__syscall_cancel_arch() 是被 C 代码当作普通函数调用的,编译器按这条规则把参数装进寄存器。所以,传进来的 8 个参数分别保存在 x0 - x7 这 8 个寄存器里。对应关系表如下:
寄存器 | C 函数参数 | 含义 |
x0 | &pd->cancelhandling | |
x1 | nr | 系统调用号(73) |
x2 .. x7 | a1 .. a6 | 系统调用的 6 个参数 |
发散一下:如果一个函数的形参个数非常多,比允许存放参数的寄存器个数还要多,那怎么办?装不下的参数丢弃掉?
6.2 .globl 与 ENTRY()
函数体开头有这么两行:
.globl __syscall_cancel_arch_start__syscall_cancel_arch_start:
第二行 __syscall_cancel_arch_start: 是一个标号(label)。名字加冒号,表示给当前这个地址起个名字。它本身不占空间,下一条指令 ldr w0, [x0] 的地址就是它的值。汇编里的跳转目标也是这样定义的,比如后面里的 1:。
第一行 .globl 是汇编器的伪指令,以点开头,不生成任何机器码,只是给汇编器下达指令。这一条的作用是把紧随其后的标号标记为全局符号,允许其它目标文件引用。
没有它,标号默认只在本文件内可见。这有点类似于全局静态变量,有 static 修饰时,只有本文件能用,去掉 static 后别的文件才能 extern 它。.globl 就相当于去掉这个 static 关键字。
那为什么这两个标号需要全局呢?
因为它们要被另一个文件里的 C 代码引用(sysdeps/nptl/cancellation-pc-check.h):
extern const char __syscall_cancel_arch_start[1];extern const char __syscall_cancel_arch_end[1];return pc >= (uintptr_t) __syscall_cancel_arch_start&& pc < (uintptr_t) __syscall_cancel_arch_end;
C 这边用 extern 声明两个数组,只是为了拿到地址。const char [1] 是个惯用技巧:不关心内容,只要地址。
很明显,下面的 return 语句是在判断当前 PC 是否落在了这个函数体内。如果没有 .globl,链接时这两个 extern 会报未定义引用。
再回头看开头的 ENTRY (__syscall_cancel_arch)。它是 glibc 的宏,在 ARM64 上展开后同样以 .globl 加标号为核心,只是多了几样函数专用的装饰:
.globl __syscall_cancel_arch.type __syscall_cancel_arch, %function.p2align 6__syscall_cancel_arch:cfi_startprocbti c
.type ... %function 声明这是个函数,.p2align 6 让入口地址按 64 字节对齐,cfi_startproc 开始记录栈回退信息,更多细节我也不太了解,就此打住。而 xxx_start 和 xxx_end 这两个标号只用裸的 .globl,没有 .type %function,因为它们不是函数入口,只是函数体内部的两个地址标记。
6.3 入口的取消检查
ldr w0, [x0]tbnz w0, TCB_CANCELED_BIT, 1f
还记得吗?x0 里保存的是取消标志的地址。ldr 指令把 x0 指向的内存里的值加载进 w0,也就是变量 pd->cancelhandling。w0 就是 x0 的低 32 位,因为 cancelhandling 是 int 型。
第二条 tbnz(test bit and branch if nonzero)指令,检查其中的"已被取消"位。如果置位了就跳到标号 1,进入 __syscall_do_cancel(),不再发起系统调用。
TCB_CANCELED_BIT 的值是 3,来自头文件 nptl/descr.h 里的 CANCELED_BIT,经 descr-const.sym 机制导出成汇编可用的常量。glibc 用这种生成机制保证 C 和汇编看到的位定义永远一致。
这属于取消机制的一部分,正常情况下不会命中。接着往下看。
6.4 系统调用约定
接下来是一连串 mov:
mov x8, x1mov x0, x2mov x1, x3mov x2, x4mov x3, x5mov x4, x6mov x5, x7
对照 6.1 的表看,它在做的事是:
1. x1 里的系统调用号搬到 x8
2. x2 到 x7 里的 6 个参数整体前移两格,搬到 x0 到 x5。
为什么要搬?
因为接下来的 svc 不是函数调用,而是系统调用。它遵守的是另一套 ABI:Linux/AArch64 系统调用 ABI,由内核规定用户态怎么调内核:
- 系统调用号放在 x8;
- 参数放在 x0 到 x5,最多 6 个;
- 返回值放在 x0;
- 用 svc 指令触发。
这和 5.2 节 INTERNAL_SYSCALL_RAW 里"_x8 装调用号、_x0 到 _x5 装参数、结果从 _x0 取"完全一致,只不过那里由编译器安排寄存器,这里则是手搓版。
所以,__syscall_cancel_arch() 站在了两套约定的交界处。以函数的身份进来,参数按函数调用约定分布在 x0 到 x7;要以系统调用的身份出去,就得按系统调用 ABI 重新摆放。那串 mov 就是在两套约定之间搬运:
syscall ABI | 来源 | 本例(ppoll)值 |
x8(调用号) | x1 | 73(__NR_ppoll) |
x0(arg1) | x2 | |
x1(arg2) | ||
x2(arg3) | timeout_ts_p | |
x3(arg4) | NULL(sigmask) | |
x4(arg5) | 0(sigsetsize) | |
x5(arg6) |
顺带留意一下 7 条 mov 指令的先后顺序。x1 先被复制到 x8,之后才被 x3 覆盖;x2 先复制到 x0,之后才被 x4 覆盖,依此类推。每个寄存器都是先被读走,再被写入覆盖。所以,可以想象,手搓汇编代码,很容易出现 bug。
6.5 陷入内核态
寄存器摆好之后,就剩一条 svc 0x0 指令了。
要理解它,得先理解 ARM64 的特权划分。ARM64 把 CPU 特权分为 4 个异常等级(Exception Level),数字越大权限越高:
- EL0:应用程序;
- EL1:操作系统内核;
- EL2:虚拟机监控器(hypervisor,如 KVM);
- EL3:安全监控器(固件 / TrustZone)。
普通 Linux 进程跑在 EL0,内核跑在 EL1。EL0 无权执行特权操作,想要内核办事就得喊一嗓子。svc(Supervisor Call)指令就是这一嗓子。svc 执行后 CPU 产生一个同步异常,切换到 EL1,从内核异常向量表的对应入口开始执行,自此就进入内核态了。同族指令还有 hvc(陷入 EL2)和 smc(陷入 EL3),本文用不到,点到为止。
两个点解释一下:
- 立即数 0x0 是约定俗成的固定值,不携带系统调用号,Linux 忽略它。调用号只看 x8。
- 内核处理完毕后执行 eret 降回 EL0,结果通过 x0 返回。成功时是就绪的 fd 个数,失败时是负的错误码。执行流回到 __syscall_cancel_arch_end 处继续 ret。按函数调用约定,返回值恰好也在 x0,一个字节都不用动,直接返回到 5.3 节的 result,再回到第 4 节做错误码转换,最终回到用户代码。
到这里,poll() 在用户空间的旅程就走完了。下一篇将分析内核里系统调用的分发流程,也就是怎么根据系统调用号 73 最终锁定了 sys_ppoll() 函数。