5.3 内联汇编与编译器输出对照


5.3 内联汇编与编译器输出对照

本节摘要:GCC 的内联汇编语法把"你想执行什么"与"数据怎么进出"分离成模板、输出列表、输入列表、破坏列表四件套。本节从一条最小的内联汇编起步,讲清约束符与破坏列表的语义,再做一次人机对照实验:同一功能分别由人手写与编译器生成,看谁写得好、好在哪。

前两节读的都是编译器的输出。这一节换一个方向:往 C 里汇编。合理的第一反应是"编译器不是比我写得好吗"——大多数时候是。但三类场景值得人下场:访问编译器没有对应 C 语义的指令(屏障、系统寄存器读取、原子指令);精确控制寄存器与时序的底层操作(上下文切换、启动代码);以及对极小的热循环做最后一搏。本节的写法是先学会语法,再做对照实验——对照完你会对"什么时候该人下场"有直接的体感。

最小可用样本

内联汇编的完整骨架是四件套:模板、输出、输入、破坏。从一个"读当前栈指针"的经典例子起步——这正是启动代码与内核代码的高频需求:

static inline void *current_sp(void) { void *sp; __asm__ volatile ("mov %0, sp" : "=r"(sp)); return sp; }

逐件拆解:模板串里 %0 是占位符,指向输出列表第一项;"=r" 是约束符,等号声明只写,r 表示"给我一个通用寄存器",具体哪个由编译器分配——注意这不是你指定寄存器,而是你借用编译器的分配结果;括号里的 C 变量 sp 承接结果。整个表达式的含义:执行 mov 把 sp 搬到某个寄存器,再把那个寄存器的值当作 sp 变量。volatile 告诉编译器别把这段挪动或删除(它有副作用语义,优化器看不见)。

进阶一档,做一个带输入输出的两操作数版本——把一个 64 位值按字节反转(等价于一条 rev 指令,正好演示"编译器没有 C 语义的指令"场景):

static inline unsigned long bswap64_arm(unsigned long x) { __asm__ ("rev %0, %1" : "=r"(x) : "r"(x)); return x; }

两处细节是内联汇编的正确性命门。其一,输出输入共用了变量 x 但声明成"输出只写",告诉编译器初值不需要送进寄存器——这在语义上碰巧安全(rev 不读 %0),但更严谨的写法是输出约束用 "=r" 而把输入单独再列一次,或用 "0" 匹配约束声明同寄存器。其二,如果一条指令会顺手改掉标志位或某个你没列出的寄存器,必须在破坏列表里声明,否则编译器对那些位置的假设就全错——这也是下面破坏列表一节的主题。

破坏列表:容易被轻视的正确性命门

破坏列表声明"我的汇编还会动这些地方,你别把重要东西放那"。三个最常用的成员:"cc"(标志位会被改)、"memory"(内存内容可能被动过,禁止优化器跨这条汇编缓存读取)、以及显式寄存器名如 "x19"(极少见,等于宣告你违反了调用合同,编译器会 angry)。一个必须带 "memory" 的实例——自写的简单内存写屏障语义:

static inline void write_and_fence(long *p, long v) { __asm__ volatile ( "str %1, [%0] \n\t" "dmb ish \n\t" :: "r"(p), "r"(v) : "memory" ); }

没有 "memory",优化器有权认为它对内存的了解仍然新鲜,把后续读操作缓存进寄存器——而这条汇编的全部意义恰恰是"内存世界变了"。这类 bug 的表现是"关掉优化就正常,开了优化就闹鬼",是内联汇编最经典的坑。

人机对照实验

现在做实验:同一个"数组元素逐个翻转字节序"的任务,一个版本用 C 循环调用上面的内联汇编,一个版本直接写纯汇编函数,一个版本用纯 C 写循环加移位拼装。全部 -O2 编译后看产物:

纯 C 移位拼装版(-O2):编译器自动用上了 ldr 加 rev,内层循环 4 条指令 内联汇编版:rev 一条,但循环控制与访存仍是编译器生成,合计与纯 C 相同 手写纯汇编版:rev 加后索引 ldr 加 cbnz,4 条,与编译器版同构

解读:三者几乎打平——因为 rev 这类指令早就在编译器的"词汇表"里,纯 C 版本它照样能用上。人下场的价值在编译器够不着的地方:读取系统计时器、写屏障、上下文切换。变式:把任务换成"读 cntvct_el0 计时器",试试用纯 C 写——你会发现根本无从下手,这类系统寄存器只能走 mrs,内联汇编是唯一通道;而如果换成 smull 乘法这类编译器也会选的指令,人手写反而是负资产,因为它剥夺了优化器跨函数调度的自由。这个对照实验每做一轮,"什么时候该手写"的判断就准一分。最后一条工程纪律:内联汇编越短越好,能封装成 static inline 小函数就封装,破坏列表宁多勿少——多声明破坏是保守,漏声明是事故。

延伸:读周期计数器——内联汇编的招牌应用

5.3 正文说过读系统寄存器只能走内联汇编,把它做成完整可抄的样本:

static inline unsigned long read_cntvct(void) { unsigned long v; __asm__ volatile ("mrs %0, cntvct_el0" : "=r"(v)); return v; } // 用法:起点终点各读一次,差值即虚拟计时器 tick 数 unsigned long t0 = read_cntvct(); work(); unsigned long dt = read_cntvct() - t0;

这个样本是性能实验的地基(第 8 章的收益核算全靠它),也是内联汇编四件套的标准展示:模板一条 mrs,输出一个 =r,输入没有,破坏列表也没有——mrs 不碰标志与内存,什么都不用声明。该省就省该防就防(屏障类加 "memory")构成同一枚硬币的两面。

另一个实用约束值得知道:占位符之外,GCC 还支持把 C 表达式直接嵌进模板的 %c0 形态与立即数约束 "I"(要求编译器保证某输入是可编码的立即数,否则编译期报错)。用它可以让汇编模板在编译期就锁定合法立即数——3.2 节讲过立即数有格式限制,"I" 约束就是把这个限制前移到编译期,错误从运行玄学变成编译报错。

本节要点回顾

  • 四件套:模板、输出、输入、破坏,占位符按输出列表序号排。
  • 约束符语义=r 是借用编译器分配的寄存器,不是指定寄存器。
  • volatile 与 memory:前者禁删禁挪,后者禁止跨段缓存内存读取,屏障类必带。
  • 对照结论:编译器够不着的指令才值得人下场,够着的地方手写是负资产。
  • 封装纪律:内联汇编写成短小的内联函数,破坏列表保守声明。

人工干预的边界摸清了,控制流一章收官。下一章进入处理器"被打断"的世界:异常、中断与裸机启动。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U