使用汇编编写多线程程序

本文的运行环境为 ubuntu 20.04,编译环境为NASM version 2.14.02。文中代码不使用 pthread 等库。

如果需要在windows下使用汇编编写多线程程序,可以参考这个视频


准备工作

为了让代码段更加简介,使用了 nasm 的一个头文件库,它将大部分的c语言头文件转换成了汇编可链接的inc文件。

需要注意的是库里面可能有错误:

one warning is in place: Not all includes are tested and not all in one include file is tested. Use at your own risk or if in doubt, use your include files.
I’ve tried my best but even until today I find errors (typos mostly)

如何使用该头文件库

基本格式如下:

1
2
nasm -f elf64 name.asm -o name.o -i /path/to/linux-nasm/Include-Files
ld -melf_x86_64 name.o -o name

需要说明的是:1

  • nasm进行编译,其中:
    • -f 指定要编译的格式,linux 使用 elf 即可
    • -o 指定编译后的名称
    • -i %include指定要查找的目录,可以在 NASMENV 环境变量里设置固定的路径。(万一这个头文件库有奇怪的问题呢,所以不建议设置固定路径)
  • ld 命令是GNU的连接器,将目标文件连接为可执行程序。

也可以用 && 将上面两条指令合并起来。

nasm和yasm的区别

略。


代码说明

代码部分参考了threads.asmpure-linux-threads-demo,说明部分参考了借由系统调用实现 Linux 原生线程Raw Linux Threads via System Calls

汇编参数的传递

当参数少于7个时, 参数从左到右放入寄存器:rdi , rsi , rdx , rcx , r8 , r9 。当参数为7个以上时, 前 6 个与前面一样, 但后面的依次从 “右向左” 放入栈中,即和32位汇编一样。2

下面是第一个创建线程的调用,rdi 中保存的是需要传参的第一个线程的开始地址:

1
2
mov	    rdi, threadfn1
call thread_create

系统调用和一般的函数调用不同。3

在64位 x86_64 Linux 系统中,可用的系统调用定义在 /usr/include/asm/unistd_64.h 头文件中。

每个系统调用都对应一个编号 以及 若干个参数。如果想使用汇编语言调用系统调用,那么在调用之前,需要将系统调用编号存到 rax,将参数依次存到 rdi , rsi , rdx , r10 , r8 , r9 中,然后再执行syscall 指令即可。

每个系统调用的编号和参数列表可以参考此文档

一个输出的调用如下:

1
2
3
mov rdi, STDOUT
mov rax, SYS_write
syscall

如果使用了本文中的库,系统调用可以简化如下:

1
syscall write, stdout

栈空间的分配

对于 flags,考虑到我们将用这块内存作为线程栈,我们将选择私有、匿名、向下生长。不过,哪怕设置了向下生长,系统调用 mmap() 仍然会返回内存映射的底部地址。一会儿会用到这一重要信息。于是,事情就简单了:只需要将寄存器的值设置好,而后执行 syscall 指令即可。4

线程的创建

参考文章中已经讲的很清楚了,只针对一点再做一下解释。

最酷的地方来了,我们来看看为什么不需要指令分支;即,为什么我们没有必要检查 rax 的值,来确定是原始线程(返回到调用者)还是新线程(跳转到线程函数)?注意新线程的栈顶部保存着指向线程函数的指针:当函数在新线程返回时,执行序列会跳转到线程函数,且线程栈是空的。而原始线程则会使用原始线程的栈,返回调用者。4

在函数调用的时候,会将当前地址压栈,以方便 ret 的时候返回。这里做了一个类似的操作,将线程函数地址压栈,这样新线程返回的时候就会直接跳转到线程函数的地址,从而达到不检查 rax 的值的效果。

清理现场

使用用 munmap()释放线程栈,以防资源泄漏。但是两份参考代码都没有做这件事。如果你不想要原始线程一直输出的话。

主线程中等价于 pthread_join() 的系统调用是 wait4()

wait3等待所有的子进程;wait4可以像 waitpid 一样指定要等待的子进程:pid >0表示子进程ID;pid =0表示当前进程组中的子进程;pid =-1表示等待所有子进程;pid <-1表示进程组ID为 pid 绝对值的子进程。5

具体的可以参考之前提到的系统调用编号和参数列表文档。


基础分析

这才是真正有意思的地方了,前面的都只是铺垫,但是我打算之后再展开写,现在先大致的记录一下。

使用 perf 6可以观察到两个线程之间的竞争关系。

使用 ps -a -T 可以得到所有的线程号。使用 sudo perf stat -d -t tid 可以对某线程进行分析,还可以使用 -e 指定具体的PMU计数器。

摘录三个线程中的一个结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Performance counter stats for thread id '45670':

5,980.79 msec task-clock # 0.166 CPUs utilized
276,625 context-switches # 0.046 M/sec
189 cpu-migrations # 0.032 K/sec
0 page-faults # 0.000 K/sec
10,416,963,411 cycles # 1.742 GHz
6,128,956,714 instructions # 0.59 insn per cycle
1,441,731,647 branches # 241.061 M/sec
22,577,430 branch-misses # 1.57% of all branches
1,777,332,012 L1-dcache-loads # 297.174 M/sec
162,317,327 L1-dcache-load-misses # 9.13% of all L1-dcache accesses
<not supported> LLC-loads
<not supported> LLC-load-misses

36.042847360 seconds time elapsed

参考

Author

moep0

Posted on

2021-11-01

Updated on

2021-11-29

Licensed under

Comments