03 · 写:内联汇编,把上一篇欠下的顺手还上
上一篇收尾的时候,咱们桌上留了几张生面孔:push 和 pop 在栈上的一进一出,hlt 的那一睡,ret 凭什么认得回家的路,还有参数为什么总从 %edi 和 %rsi 进来。当时说好了,轮到写汇编的时候顺手还上。现在就是这个时候:这一篇咱们要学的就是把汇编亲手塞进 C++ 里,这门手艺的名字就叫内联汇编。咱们要塞进去的那几行,住的地方恰好就是这些老面孔的地盘,函数的门口是序言在把门,进出走的是那口栈。所以咱们从栈说起,欠下的几笔一笔一笔还上。
push 和 pop:栈的一进一出
push 的语义,手册里就给了一句话:头一步把 %rsp 减下去,第二步把操作数存进新的栈顶。pop 倒是反着来:从栈顶取走数据,再把 %rsp 加了回来。x86-64 的一趟是 8 个字节。您别小看这对动作,咱们手搓一段编出来,它们可是汇编里最省字节的指令之一:
cat > pp.s <<'EOF'
.text
.globl PushPopBytes
PushPopBytes:
pushq %rax
popq %rbx
pushq %r12
popq %r12
ret
EOF
as pp.s -o pp.o
objdump -d pp.o0000000000000000 <PushPopBytes>:
0: 50 push %rax
1: 5b pop %rbx
2: 41 54 push %r12
4: 41 5c pop %r12
6: c3 ret咱们上一篇的 movq 搬一个立即数要用七个字节,咱们这里的 pushq %rax 只有一个字节 50(输出开头的两行文件头,咱们一律略去不贴)。低位的八个寄存器(rax 到 rdi)各占一个编码,58 起头的是 pop 家族,而 r8 到 r15 得加一颗 41 前缀才变成两个字节。您再看看尾巴上那个 ret:也才一个字节 c3。
光看字节不过瘾,咱们请 gdb 来单步走一遍,亲眼看 %rsp 怎么动。这段小程序干完活就调 Linux 的退出系统调用收工:
cat > pushwalk.s <<'EOF'
.text
.globl _start
_start:
pushq %rax
pushq %rbx
popq %rbx
popq %rax
movq $60, %rax
xorl %edi, %edi
syscall
EOF
as pushwalk.s -o pushwalk.o
ld -o pushwalk pushwalk.o
gdb -q -batch -ex 'break *_start' -ex 'run' -ex 'p/x $rsp' -ex 'x/i $rip' -ex 'si' -ex 'p/x $rsp' -ex 'si' -ex 'p/x $rsp' -ex 'si' -ex 'p/x $rsp' -ex 'si' -ex 'p/x $rsp' -ex 'x/i $rip' pushwalk咱们拿一条 gdb 命令在本机实测过:进 _start 时 %rsp 是 0x7fffffffd1e0,咱们 push 一次看到它减 8 变成 d1d8,再 push 一次到了 d1d0,pop 两次之后它又原路退回了 d1e0。您看这一进一出分毫不差,8 就是 x86-64 栈的粒度。gdb 默认关掉了地址随机化,所以您每次重跑看到的都是同一个数。不过正文里这串数值是本机实录,换台机器、换套环境变量数值就变了,您别拿它对表。开头那几行 debuginfod 询问是 gdb 的环境噪音,跟咱们的实验无关,您不想看见它的话,下一节 call 实验的命令开头就带了 set debuginfod enabled off,咱们原样搬过去用就行。
call 与 ret:返回地址就躺在栈顶
上一篇咱们说过 call 替咱们把返回地址压进了栈,而 jmp 跳过去就不管回程。现在咱们有了 push 的知识,可以把这句话兑现成看得见的字节。您写一个 _start 一进门就 call、Target 里只放一句 ret 的小程序,那两行 movq $60 加 syscall 是 Linux 的 60 号退出调用,咱们借它让程序体面退场:
cat > callret.s <<'EOF'
.text
.globl _start
_start:
call Target
AfterCall:
movq $60, %rax
xorl %edi, %edi
syscall
Target:
ret
EOF
as callret.s -o callret.o
ld -o callret callret.o
gdb -q -batch -ex 'set debuginfod enabled off' -ex 'break *_start' -ex 'run' \
-ex 'p/x $rsp' -ex 'x/i $rip' -ex 'si' -ex 'p/x $rsp' -ex 'x/i $rip' \
-ex 'x/gx $rsp' -ex 'x/i *(void**)$rsp' -ex 'si' -ex 'p/x $rsp' -ex 'x/i $rip' callretBreakpoint 1 at 0x401000
Breakpoint 1, 0x0000000000401000 in _start ()
$1 = 0x7fffffffd1e0
=> 0x401000 <_start>: call 0x401010 <Target>
0x0000000000401010 in Target ()
$2 = 0x7fffffffd1d8
=> 0x401010 <Target>: ret
0x7fffffffd1d8: 0x0000000000401005
0x401005 <AfterCall>: mov $0x3c,%rax
0x0000000000401005 in AfterCall ()
$3 = 0x7fffffffd1e0
=> 0x401005 <AfterCall>: mov $0x3c,%rax咱们一步步看。咱们等 call 执行完,看见 %rsp 从 d1e0 变成了 d1d8,又一次减了 8,call 干的活就是“把返回地址 push 进栈,然后跳过去”。栈顶现在躺着的是什么?x/gx $rsp 替咱们答了:0x0000000000401005,而 401005 正是 AfterCall,call 的下一条指令的地址。ret 干的事就是把它弹进 %rip,您看单步之后咱们果然落在了 AfterCall,而且 %rsp 回到了 d1e0。开头咱们欠下的那句“ret 凭什么认得回家的路”,答案现在就躺在栈顶:call 出发时压下的返回地址,就是它回家的字条。
序言与尾声:函数门口的那两行
栈和 call 都在手上了,工具链那一卷里 g++ 亲手写下的 pushq %rbp 和 movq %rsp, %rbp 两行,现在可以逐字读了。咱们现场写一个 Add,拿 -O0 把它编成字节给您看:
printf 'int Add(int left, int right) {\n int sum = left + right;\n return sum;\n}\nint main() { return Add(3, 4); }\n' \
| g++ -x c++ -O0 -c -o add.o -
objdump -d add.o0000000000000000 <_Z3Addii>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d ec mov %edi,-0x14(%rbp)
7: 89 75 e8 mov %esi,-0x18(%rbp)
a: 8b 55 ec mov -0x14(%rbp),%edx
d: 8b 45 e8 mov -0x18(%rbp),%eax
10: 01 d0 add %edx,%eax
12: 89 45 fc mov %eax,-0x4(%rbp)
15: 8b 45 fc mov -0x4(%rbp),%eax
18: 5d pop %rbp
19: c3 ret咱们来认序言:就是开头那两行、字节 55 48 89 e5。pushq %rbp 把调用者的 %rbp 存进栈,和 call 压返回地址是同一个动作,movq %rsp, %rbp 让 %rbp 指向当前的栈顶。咱们从这一行往后看,%rbp 在函数里就不动了,后面所有 -0x14(%rbp)、-0x4(%rbp) 这样的偏移,锚的都是它。行话管这叫帧指针:参数和局部变量在栈里的位置,咱们全靠它来定位。咱们再看尾声,干的是 popq %rbp 加 ret,把存的旧 %rbp 还回去,再顺着 call 留的字条回家:字节 5d c3。咱们看同一个输出里 main 的字节也是这套开门关门,中间它给 %edi、%esi 装了参数,又夹了一条 call 和一条 nop,您已经全读得懂了。
还有一个名叫 leave 的 c9 您迟早会见到。咱们让 leave 一条指令干两件事:把 %rsp 拉回 %rbp,再 pop 出旧的 %rbp,给动过 %rsp 的函数当收尾的快捷键。最简单的函数里 GCC 不用它,直接 pop 就够了。可一旦函数把 %rsp 挪走过了(比如开了变长数组),pop 就找不着北了,咱们就得请 leave 出场。咱们拿一个带变长数组的小函数实测过,它的尾声正是 leave 配 ret:字节 c9 c3。
那序言是不是永远都在?您把上面命令里的 -O0 换成 -O1、-c 换成 -S,重新给咱们来一遍,_Z3Addii 的正文(标号行略)就成了:
_Z3Addii:
leal (%rdi,%rsi), %eax
ret咱们眼前的序言尾声双双消失,Add 缩成了一条 leal,main 更是把整个调用在编译期算成了 movl $7, %eax 加一条 ret,您翻自己刚编出来的 .s 就能看见。帧指针只是 -O0 的拐杖,一旦优化开了,编译器自己其实记得清清楚楚,就不劳 %rbp 值班了。
hlt:一个字节的睡觉指令,用户态碰不得
上一篇 _start 的尾巴上躺着一个 hlt,咱们现在可以交代它的字节了:单个的 f4,您把它编成 .s 过一遍 as 和 objdump 就能亲手确认。它的语义是把处理器放进停机状态,等中断来了再醒,醒了从 hlt 的下一条指令继续,内核空闲时转的就是它。咱们读得到也编得出,那在咱们日常的机器上跑一下会怎样?您猜猜。咱们大胆试,反正死的是进程自己,伤不着咱们的机器:
printf 'int main() { asm volatile("hlt"); return 0; }\n' \
| g++ -x c++ -O2 -o hlt_um -
./hlt_um; echo "exit_status=$?"exit_status=139进程被杀了,139 = 128 + 11,咱们查 11 号信号就是 SIGSEGV。这里请您留意一层:它不是读了非法内存,而是 hlt 在保护模式下是特权指令,您在用户态执行它,处理器抛的是通用保护异常,Linux 内核再把这个异常翻译成了 SIGSEGV 递给进程。看系统调用的 strace 还能替咱们作证,它报的是 SIGSEGV {si_signo=SIGSEGV, si_code=SI_KERNEL, si_addr=NULL},内核主动发的,没带出错地址,跟野指针的页错误长得不一样。所以同一个 f4,咱们在内核态跑它是正经的省电,咱们在用户态跑它就是越权,差的只是当前特权级。“特权指令”这个名字咱们在本卷第一篇见过,可咱们这还是头一回亲眼看到它发作,往后讲运行模式的篇章里咱们再收拾它。
参数从哪儿进,结果从哪儿回
最后一笔欠下的:参数为什么总从 %edi 和 %rsi 进来。答案其实不神秘,x86-64 的 Linux 上大家守的是同一份调用约定:头六个整数或指针参数,咱们让它们依次走 %rdi %rsi %rdx %rcx %r8 %r9,整数返回值走的还是 %rax,轮到第七个参数就只能上栈了。咱们拿一个两参函数的 -O0 汇编看现场:
_Z4Sum2ii:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret咱们看 a 和 b 从 %edi、%esi 进场,算完的结果从 %eax 回家,一目了然。真正有意思的是七个参数的现场,咱们用 -O1 -fno-inline 把内联关掉再编(不关的话,整个调用就被编译期直接算成了一个数。咱们贴的现场截到第二条 call 为止,剩下的几行收尾您自己跑出来看):
main:
pushq %rbx
movl $20, %esi
movl $10, %edi
call _Z4Sum2ii
movl %eax, %ebx
pushq $7
movl $6, %r9d
movl $5, %r8d
movl $4, %ecx
movl $3, %edx
movl $2, %esi
movl $1, %edi
call _Z7Sum7iiiiiii咱们看前六个参数各就各位,第七个参数是一条字面意思的 pushq $7 压进栈的,咱们刚学的 push 在这儿就派上了用场。被调方取它的时候,-O0 下从 16(%rbp) 读(%rbp 往上 8 格住的是返回地址、+16 才轮到第一个栈参数),省了帧指针时从 8(%rsp) 读,紧贴返回地址上方。咱们到这里,上一篇留下的几张生面孔就都见过了,接下来咱们正儿八经开新手艺,从这儿起所有实验您在自己的机器上都能原样跑,需要的只有 g++ 和 objdump。
基本 asm:原样塞进去的第一段
C++ 里的 asm 是标准关键字,咱们直接写 asm 就好。您在别人的代码里见过的 __asm__ 是同一件事的保险拼法,防的是个别编译选项把 asm 关掉,咱们平时用不上。最朴素的嵌法叫基本 asm,括号里写的只有一个指令字符串,没有别的:
cat > basic.cpp <<'EOF'
int main() {
asm("nop");
asm("nop\n\tnop\n\tnop");
return 0;
}
EOF
g++ -O2 -S basic.cpp -o basic.s
grep -n -B1 -A7 '#APP' basic.s9- .cfi_startproc
10:#APP
11-# 2 "basic.cpp" 1
12- nop
13-# 0 "" 2
14-# 3 "basic.cpp" 1
15- nop
16- nop
17- nop(您机器上第 11、14 行里会是您自己的文件路径。)#APP 和 #NO_APP 是 GCC 在汇编输出里划的围栏,意思咱们已经能读懂:“围栏里这段是作者亲笔,我一个字没动”。咱们写进去的四个 nop 原样躺在里面,两条独立的 asm 还被并进了同一对围栏。以后您在 .s 里找自己嵌的那段,咱们就认这对书签。有个小提醒:老版本 GCC 写的是 # APP(带空格),GCC 16 写的是 #APP,grep 的花样请按您自己的输出来。
咱们写给它的字符串会被 GCC 一个字不改地纯拷贝进去,里面的 % 不当操作符用,寄存器咱们就写单个的 %eax。您要是手滑写成 %%eax,g++ -S 都不给您报错,错要等到汇编器那一道才炸了出来,报的是 bad register name '%%eax'。它也没有任何引用 C 变量的机制,顶多把全局变量的名字直接写进指令串。可 C++ 的函数名在符号表里是被修饰过的,Foo(int) 的真名是 _Z3Fooi,您在基本 asm 里 call Foo,链接器只当您要调一个从没定义过的 Foo,跟您当场翻脸。还有一层跟版本有关:很新的 GCC 16 对基本 asm 相当保守,它假定您这段指令不动任何通用寄存器,不过它可能读写任何全局变量,手册还明说了:该假定将来可能再变。所以基本 asm 在咱们这儿只当引子,真正要学的是下一位。
扩展 asm:让 GCC 当中间人
基本 asm 的问题在于,GCC 对围栏里的事一无所知,它既不知道您要读哪个变量,也不知道您改了哪个寄存器。扩展 asm 补的就是一条对话通道,咱们把样子摆出来:asm(模板 : 输出 : 输入 : clobber),四段是用冒号隔开的,没内容的段可以空着。咱们从最小的一个开始,咱们把 42 装进变量:
cat > ext.cpp <<'EOF'
int Load42() {
int x = 0;
asm("movl $42, %0" : "=r"(x));
return x;
}
int main() { return Load42(); }
EOF
g++ -O2 -S ext.cpp -o ext.s咱们在 .s 里找 Load42,围栏里看到的是:
_Z6Load42v:
.LFB0:
.cfi_startproc
#APP
# 3 "ext.cpp" 1
movl $42, %eax
# 0 "" 2
#NO_APP
ret咱们看模板里的 %0,它引用的就是输出 x。咱们来谈约束 "+r":= 说的是“我只写它”,r 说的是“请给我一个通用寄存器”,落到的 %eax 就是 GCC 挑中的寄存器。这里有两件容易想反的事。一是扩展 asm 里 % 变成了操作符,写字面的寄存器名要 %%eax,操作数引用才有资格用单个的 %,跟基本 asm 正好掉了个个儿。二是咱们别指望 %0 就是 %eax,咱们只把它当成一个代号:第一个操作数落在哪儿,由 GCC 的寄存器分配说了算。咱们实测过一个热闹的版本,同一个函数里放三条各自递增一个变量的 asm("incl %0" : "+r"(v)),三条一模一样的模板,内联展开后分别落进了 %eax、%ecx 和 %edx。同一个函数留在外面没被内联的独立副本里,落到的又是另一组安排。所以咱们立一条硬规范:模板里永远用 %0 %1 引用操作数,您永远别猜它是哪个寄存器。顺带您刚见的 "+r" 是 = 的亲戚,加号说的是“我又读又写”,进出的是同一个寄存器。老一点的写法是输出 "=r"(y) 配输入 "0"(x),数字约束的意思是“这个输入跟 0 号输出住同一处”。两种都是合法的,+ 是后来更省事的写法。还有一条 GCC 的硬规定顺便交代:纯输入的操作数不许改,编译器认定它出了 asm 还是原值,您要是动了它,没有任何 clobber 能替您申报,只能换成刚才的匹配写法绑一个输出。
咱们把多条指令写进同一个字符串,用的分隔符是 \n\t、每条一行,开头咱们那串 nop 就是这个样子。输入段照同样的写法挂变量:asm("movl %1, %0" : "=r"(y) : "r"(x)),编号要等输出数完了才轮到输入,所以 %0 是 y、%1 是 x。
约束字母里有一位容易被人念歪的:
"i"是立即数整数常数,而"N"不是“数字”的泛称,它是 x86 专属的机器约束,专指的是无符号 8 位常数,是 in/out 端口指令要的形状。比如咱们写asm volatile("outb %0, %1" : : "a"(v), "N"(0x60)),这里的"a"是点名要操作数落进%eax系寄存器的写法,"N"接的 0x60 就是那个端口号。咱们拿它去接非常数,编译器回您一句impossible constraint。
=r 与 =m:操作数住寄存器还是住内存
r 的兄弟 m 走的是另一条路:操作数不进寄存器、直接落在内存上。咱们拿同一个函数的两份写法对照着编成字节:
printf 'int BumpR(int x) { asm("incl %%0" : "+r"(x)); return x; }\n' \
| g++ -x c++ -O2 -c -o bump_r.o -
printf 'int BumpM(int x) { asm("incl %%0" : "+m"(x)); return x; }\n' \
| g++ -x c++ -O2 -c -o bump_m.o -
objdump -d bump_r.o
objdump -d bump_m.o0000000000000000 <_Z5BumpRi>:
0: 89 f8 mov %edi,%eax
2: ff c0 inc %eax
4: c3 ret
0000000000000000 <_Z5BumpMi>:
0: 89 7c 24 fc mov %edi,-0x4(%rsp)
4: ff 44 24 fc incl -0x4(%rsp)
8: 8b 44 24 fc mov -0x4(%rsp),%eax
c: c3 ret(两段输出咱们各取反汇编正文,文件头就略了。)咱们看字节:r 版里 x 全程活在寄存器中,inc 两个字节就完事了。m 版 GCC 让 %edi 在 -4(%rsp) 的栈槽安家,随后的 incl 直接对着内存做读改写,最后再取了回来放进 %eax 当返回值。哪个好?咱们看指令有没有寄存器形态:incl 两种都行,那当然 r 更快。可往后咱们会碰到的 lgdt、lidt 这类加载描述表的指令(切换运行模式的时候,咱们会让 CPU 来装一张那样的大表),咱们喂给它的操作数天生只能落在内存,那时候 m 就是唯一的路。咱们多念一嘴,-4(%rsp) 这个位置落在栈指针下方的 128 字节里,咱们的行话叫它红区,是那些自己不再 call 别人的函数(叶子函数)可以白用的地盘。不过它是用户态的福利,进了内核就被关掉,到时候咱们再细说。
漏了 "memory" 的那个 bug
四段里的最后一段 clobber,咱们用一个真 bug 来认识它。下面是教学用的错误版,您抄下来跑,它真的会错:
cat > cand_c.cpp <<'EOF'
#include <cstdio>
int g = 1;
int main() {
int first = g;
asm("movl $99, g(%%rip)" ::);
int second = g;
std::printf("first=%d second=%d\n", first, second);
return 0;
}
EOF
g++ -O2 cand_c.cpp -o cand_c
./cand_c; echo "exit_status=$?"first=1 second=1
exit_status=0咱们在 asm 里明明白白写了把 99 存进 g,再读出来的 second 居然还是 1!您别急着看修法,咱们按老办法翻 .s 找机制:
main:
subq $8, %rsp
movl g(%rip), %esi
#APP
# 7 "cand_c.cpp" 1
movl $99, g(%rip)
# 0 "" 2
#NO_APP
leaq .LC0(%rip), %rdi
movl %esi, %edx
xorl %eax, %eax
call printf@PLT咱们把真相找出来了:第一次读 g 落在 %esi 里,围栏之后的第二次读压根没有发生,GCC 直接把 %esi 顶了上去。扩展 asm 的合同是“模板是个黑盒,它跟 C 世界的全部往来,都写在操作数和 clobber 里”。咱们没写任何 clobber,GCC 就认定了内存世界没人碰过、旧值接着用,在它看来天经地义。另外两件小事咱们记一下:一是 %%rip 在这儿要双百分号,因为咱们写的是扩展 asm。二是 -O0 下这个程序打印的 second 是对的,-O1 起才翻了车。“没优化没事,一优化出事”,刚才咱们踩的就是它,往后夜里遇上的同类也是。
咱们来修,要补的只有一行:把第四段补上:
cat > cand_c_fixed.cpp <<'EOF'
#include <cstdio>
int g = 1;
int main() {
int first = g;
asm("movl $99, g(%%rip)" ::: "memory");
int second = g;
std::printf("first=%d second=%d\n", first, second);
return 0;
}
EOF
g++ -O2 cand_c_fixed.cpp -o cand_c_fixed
./cand_c_fixed; echo "exit_status=$?"first=1 second=99
exit_status=0"memory" 对 GCC 说的是“这块 asm 动过操作数之外的内存,别信你手里存的旧值”。咱们再翻 .s,围栏之后老老实实多了一条 movl g(%rip), %edx,一行 clobber 就换回了一次真加载。clobber 段还能登记寄存器的名字,咱们按名字写、不带 %,比如您写 "ebx"。做算术的 asm 常配一个 "cc",申报的是标志位被动过。咱们再回到刚才那次真加载:它是 GCC 在编译期补的,所以 "memory" 管的也只是编译器这一层,处理器自己的乱序推测读它管不着,内存屏障的深水咱们现在不去。
asm volatile:保住的是命,不是顺序
您可能听过“加个 volatile 就稳了”。咱们现场验一验它到底保什么。下面的 asm 有寄存器输出,可 x 之后就没人用了:
printf '#include <cstdio>\nint main() {\n int x = 0;\n asm("movl $42, %%0" : "=r"(x));\n std::printf("done\\n");\n return 0;\n}\n' \
| g++ -x c++ -O2 -S -o drop.s -
grep -c 'APP' drop.s0咱们数围栏数出来是 0,整段 asm 在 -O2 下被删了个干干净净、程序照跑照打印 done,删了您也察觉不到。咱们给 asm 加上 volatile 再编一次,计数就变成了 2,movl $42, %eax 原样回来了。这就是 volatile 的本职:照我写的样子执行,您别因为输出没人要就把整段当死代码扫地出门。往后咱们写端口 IO 这类全靠副作用的指令,到时候全指着它给咱们保命。另外有个省心的规律:没有输出操作数的 asm 天然就是 volatile,咱们的 asm("hlt") 不写 volatile 也不会被删。
volatile 不保的东西,咱们同样得记清。咱们看头一件:它不是内存屏障,刚那个漏 "memory" 的 bug 您翻回去看,修法补的才是内存申报,volatile 可救不了它。顺序咱们也别指望它保,手册明说 volatile asm 仍然可能被编译器相对别的代码挪动,咱们连“两条 volatile asm 之间保序”一类的说法在现行手册里也找不到依据,咱们不替它背书。真需要一道编译器级的屏障,惯用的写法是 asm volatile("" : : : "memory"),一条空指令配上 memory clobber 就成了。
哪些寄存器碰得,哪些碰不得
咱们嵌的 asm 住在函数里,就等于住进了调用约定的世界。约定把寄存器分成两拨:%rbx %rbp %r12 %r13 %r14 %r15 是被调方必须原样还原的,而 %rsp 也必须进出相等,剩下的 %rax %rcx %rdx %rsi %rdi 和 %r8 到 %r11 是临时寄存器、谁用谁负责。对咱们的意义就一条:asm 里碰了谁,咱们就在 clobber 里如实登记谁。GCC 拿到咱们的申报会做两件事,给操作数挑寄存器的时候绕开这些名字,真有必要的时候还会亲手把它们存进栈再还回来。您要是碰了必须还原的那拨寄存器又瞒报,就违背了约定,靠这些寄存器保值的代码可能被安静地破坏,而编译器不会给您任何诊断。
两位特殊的得单独说。%rbp 在开着帧指针的时候(比如 -O0)进 clobber,GCC 直接就拒绝编译了,报的是 bp cannot be used in 'asm' here,省了帧指针才接受,而且它会自动帮您保存。而 %rsp 干脆不许进 clobber,GCC 16 会警告您说这个写法已经废弃。真正的规范是 asm 进出前后 %rsp 必须相等,咱们的指令只要不碰栈指针,天然就满足了。
能用 C 写,就别劳驾汇编
手艺到手了,泼冷水的忠告也该到位。其实 GCC 手册自己承认,它不解析您模板里的指令,写错了没有编译期的体检,验收的路只有看字节一条。多条独立的 asm 也不保证编译后还紧挨着,必须连续的指令请写进同一个字符串。Linux 内核的代码风格文档说得更直白:C 能干的活就别劳驾内联汇编,常见的一小段可以包成一个 C 小函数,大块的汇编进单独的 .S 文件。咱们这一篇学的约束、clobber、volatile 申报纪律,本身说的就是“为什么 C 更安全”,每一样都是在替 GCC 补它看不见的信息。内联汇编的正经用场,是 C++ 实在表达不了的那几行:端口 IO、停机、开关中断,咱们数得过来。
没讲完的,记在明处
这一篇没铺开的,咱们照例摆在桌面上:浮点和 SIMD 的约束字母、asm goto 跳 C 标签、%[名字] 式的具名操作数、%= 自制不重号的局部标号、%P 这类操作数修饰符、双方言花括号(同一段模板给 AT&T 和 Intel 各留一份写法的机关),手册里都有、咱们用到再取。机器约束字母咱们只认了 N,I 到 M 那一排各有各的形状。clobber 里还留了个 "redzone",您在 asm 里用 call、push 的时候跟它有关系。ret 带立即数弹参数的老形态、成员函数的 this 指针走 %rdi 的细节,也都不在这一篇的盘子里。另外上一篇搁下的 .code16gcc,这儿能补上一半:-m16 的底子是 32 位代码加 16 位打包,栈类指令(push、ret)按 32 位的默认编,所以那边的字节里常多一颗 66 前缀。至于寻址默认的差别,咱们还留着它,等咱们真进 16 位世界再对照。
搓启动代码之前
开篇认下的几张生面孔,现在都干过活了:call 压栈 ret 弹栈,咱们在 gdb 里一步步走过,漏 "memory" 的那个 bug,咱们亲手跑出来又亲手修好。等咱们真的动手搓启动代码,机器必须亲自动手的那几行,就是照今天这一套塞进 C++ 里的。写完了,别忘了拿 objdump 对一眼字节。