C与汇编混合编程

简介: C与汇编混合编程

引言

  • 功能越来越复杂了,用汇编实现实在太麻烦了,还是转成 C 语言吧
  • 问题:如何从汇编语言过渡到 C 语言呢?

C 与 汇编混合编程的基础条件

  • 不同语言想要实现混合编程,那么它们就必须可编译得到相同格式的目标文件
  • 如下图,C 文件与编译链接最后生成可执行程序 APP,正是由于 C 源文件与汇编源文件都可以被编译生成统一格式的 .o 目标文件,然后链接器才能把同一格式的 .o 目标文件链接生成最终可执行程序 APP

函数调用约定

  • 要将 C 语言和汇编语言结合编程,还有一些事情需要提前知晓,那就是函数调用约定
  • 不同的语言它们的调用约定并不是唯一的,其它的咱不关心,对于 C 语言,遵守的是 cdecl 约定。cdecl(C declaration,即 C 声明),约定内容如下:
  • 参数是在栈中传递的
  • 函数参数是从右到左的顺序入栈的
  • EAX、ECX 和 EDX 寄存器是由调用者保存的,其余的寄存器由被调用者保存
  • 函数的返回值存储在 EAX 寄存器
  • 由调用者清理栈空间
  • 下面我们就来一一具体分析一下这些约定
  • 首先,参数是在栈中传递的。对于这一点,应该是个常识了,参数除了可以放在栈中,也可以放在寄存器中,又或者内存中。事实证明,最好的方式就是放在栈中来保存,这有以下两个好处
  • 每个进程都有自己的栈,这就是每个内存自己的专用内存空间
  • 保存参数的内存地址不用再花精力维护,已经有栈机制来维护地址变化了,参数在栈中的位置可以通过栈顶的偏移量来得到
  • 以一个减法函数举例
int subtract(int a, int b) // 被调用者
{
  return a-b;
}
int sub = subtract(3, 2); // 主调用者
...
  • 从 C 语言的角度来看,函数 subtract 返回 a 减去 b 的差,即 3-2。但是,在其被编译成汇编语言时,参数是要压入栈中的。以上的 C 代码对应的汇编代码如下:
; 主调用者
push 2              ; 压入参数 b
push 3              ; 压入参数 a
call subtract       ; 调用函数 subtract
add esp, 8          ; 回收(清理)栈空间
...
; 被调用者
subtract:
  push ebp          ; 备份 ebp,为以后用 ebp 作为基址来寻址参数
  mov ebp, esp      ; 将当前栈顶赋值给 ebp
  mov eax, [ebp+8]  ; [ebp+8]: 参数 a
  sub eax, [ebp+12] ; [ebp+12]: 参数 b
  ;pop ebp ; 推荐使用 leave
  leave
  ret
  • 让我们一步一步的分析栈中情况

混合编程格式

  • 因为我们用 gcc 编译 C 代码时,gcc 编译器默认使用 cdecl 约定,所以无论是 C 调用汇编还是汇编调用 C,我们能做的都只是让汇编去遵守 C 的约定,也就是说我们只需要关注汇编中的格式
  • 汇编作为主调用者,即汇编调用 C 中函数时,汇编中的格式:
push 从右向左第一个参数
push 从右向左第二个参数
...
call c_func
add esp, 4*参数个数
  • 汇编作为被调用者,即 C 调用汇编中函数时,汇编中的格式:
asm_func:
  push ebp
  mov ebp, esp
  ...    ; 以 ebp 为基准 
  ...    ; [ebp+int*4] 这种形式可取得传入的参数值
  ...    ; [ebp-int*4] 这种形式可取得被调用者函数内部的局部变量值
  ;pop ebp ; 推荐使用 leave
  leave
  ret

混合编程实战

  • 实验名称:字符串打印
  • 目的:熟悉 C 与汇编混合编程
  • 具体要求:程序入口在汇编中(提示:_start),由汇编调用 C 函数 c_print,c_print 函数又调用汇编函数 asm_print,由 asm_print 打印打印字符串 "Hello World"
  • 注意:本实验所在环境为通用 ubuntu(linux)环境,所生成的可执行程序格式为 elf
  • 先提供实验完整代码:hello.asmhello.c

int 0x80 实现打印功能

  • 在实验开始前,需要做个准备工作,那就是借助 int 0x80 系统调用实现打印功能
  • 新建名为 hello.asm 的文件,其中代码如下:
[section .data]
    vstr db "Hello world", 0xA  ; 0xA:换行
    vlen equ 12
[section .text]
    global _start
_start:
    mov ebx, 1          ; fd=1:stdout
    mov ecx, vstr       ; char * buf
    mov edx, vlen       ; count
    mov eax, 4          ; sys_write
    int 0x80
    mov ebx, 0          ; 0:表示无错误
    mov eax, 1          ; sys_exit
    int 0x80
  • 编译(由于当前平台为 i386 64 位,但是我们实现的汇编代码全部都是 32 位的。所以需要将其编译成 32 位 elf 格式,目标文件名:hello_asm.o)

nasm -f elf32  hello.asm -o hello_asm.o

  • 链接 (将 hello_asm.o 链接成最终可执行程序 hello,由于当前平台为 i386 架构,链接时需要指明)

ld -s -m elf_i386 -o hello  hello_asm.o

  • 执行

./hello

  • 结果:成功打印出 "Hello world"
  • global _start 声明汇编程序入口,就像 C 中的 main 一样,当做固定格式,就这么写就行了
  • 有了 BIOS 中的中断使用经验,linux 下汇编实现打印函数可以借助 int 0x80 系统调用实现,可以从 linux 内核源码中找到 sys_call_table 这个数组,系统调用函数都在里面,eax 看做数组下标,传参方式是通过寄存器的,每个系统函数传参都不同,这个需要具体问题具体对待。
  • 示例中打印是通过 sys_write 系统实现的,其在数组 sys_call_table 中的下标为 4。sys_write 的函数原型为 int sys_write(unsigned int fd, char * buf, int count) 我们把字符串写入 fd=1(stdout) 即可实现屏幕打印效果
  • global 关键字:从汇编中导出符号
  • extern 关键字:使用外部文件中定义的符号

封装函数 asm_print

  • asm_print 作为被调用者,其格式为:
asm_print:
  push ebp
  mov ebp, esp
  xxx    
  ;pop ebp ; 推荐使用 leave
  leave
  ret
  • 然后再替换 xxx,参数用 [ebp+int*4] 形式替换,最终封装好的 asm_print 函数如下:
asm_print:
    push ebp
    mov ebp, esp
    mov ebx, 1          ; fd=1:stdout
    mov ecx, [ebp+8]    ; char * buf
    mov edx, [ebp+12]   ; count
    mov eax, 4          ; sys_write
    int 0x80
    mov ebx, 0          ; 0:表示无错误
    mov eax, 1          ; sys_exit
    int 0x80
    ;pop ebp ; 推荐使用 leave
    leave
    ret

C 中实现 c_print 函数

  • 创建 hello.c, 其中代码如下:
extern void asm_print(char * buf, int len);
int c_print(char * buf, int len)
{
    asm_print(buf, len); // 汇编中的函数可以直接以 C 的函数调用形式使用
    return 0;
}

在 hello.asm 中调用 c_print

  • 首先,我们想要使用外部定义的符号 c_print ,需要使用 extern 关键字声明

extern c_print  ; 汇编中的 extern 声明是不带参数的

  • 其次,由于 C 中用到了 asm_print 函数,所以需要导出 asm_print

global asm_print

  • 最后,在汇编中(主调用者)调用 C 函数 c_print:
push vlen       ; 参数入栈(从右向左)
push vstr       ; 参数入栈(从右向左)
call c_print    
add esp, 8      ; 回收栈空间

编译&链接&运行

  • 将改动后的程序重新编译链接
nasm -f elf32  hello.asm -o hello_asm.o
gcc -m32 -c hello.c -o hello_c.o
ld -s -m elf_i386 -o hello  hello_asm.o hello_c.o
  • 运行一下,成功显示出 "Hello world"

./hello

内嵌汇编

  • 前面我们介绍过了一种汇编方法,就是 C 代码和汇编代码分别编译,最后通过链接的方式结合在一起形成可执行文件
  • 还有另外一种形式的混合编程,那就是在 C 代码中直接嵌入汇编语言,我们称之为内嵌汇编,也称之为内联汇编
  • GCC 支持在 C 代码中直接嵌入汇编代码,但是,内嵌汇编中所用的汇编语言,其语法是 AT&T,并不是咱们熟悉的 Intel 汇编语法,GCC 只支持它

AT&T 语法简介

  • AT&T 与 Intel 汇编指令关键字并没有太大出入,在指令名字的 最后加上了操作数大小后缀,b 表示 1 字节,w 表示 2 字节,l 表示 4 字节,接下来我们对照着简单学习一下吧
区别 Intel AT&T
寄存器 寄存器前无前缀 寄存器前有前缀 %
操作数顺序 从右向左 从左向右
操作数指定大小 有关内存的操作数前要加数据类型修饰符:byte表示 8 位,word 表示 16位,dword 表示 32 位,如 mov byte[0x1234],eax 指令的最后一个字母表示操作数大小,b 表示 8 位,w 表示 16 位,l 表示 32 位。如 movl %eax, var
立即数 无前缀 有前缀 $,如 $6
远跳转 jmp far segment(段基址):offset(偏移量) ljmp $segment(段基址):$offset(偏移量)
远调用 call far segment(段基址):offset(偏移量) lcall $segment(段基址):$offset(偏移量)
远返回 ret far n Iret $n
  • AT&T 中的内存寻址还是挺独特的,需要单独说一下,其固定格式如下:
base_address(offset_address, index, size)
  • 该格式对应的表达式为:
base_address + offset_address + index*size
  • base_address 是基地址,可以为整数、变量名,可正可负
  • offset_address 是偏移地址,必须是那 8 个通用寄存器之一
  • index 是索引值,必须是那 8 个通用寄存器之一
  • size 是个长度,只能是 1、2、4、8
寻址方式 说明
直接寻址 此寻址中只有 base_address 项,即后面括号中的内容全不要,base_address 便为内存啦,比如 movl $255,0xc00008F0
寄存器间接寻址 此寻址中只有 offset_address 项,即格式为(offset_address),要记得,offset_address 只能是通用寄存器。寄存器中是地址,不要忘记格式中的圆括号,如 mov (%eax), %ebx,功能是将地址(eax)所指向的内存复制 4 字节到寄存器 ebx
寄存器相对寻址 此寻址中有 offset_address 项和 base_address 项,即格式为 base_address(offset_address)。这样得出的内存地址是基址+偏移地址之和。 movb -4(%ebx), %al,功能是将地址(ebx-4)所指向的内存复制 1 字节到寄存器 al
变址寻址 一共有以下 4 种变址寻址组合1. 无 base_address,无 offset_address:movl %eax, (,%esi,2),功能是将 eax 的值写入 esi*2 所指向的内存2. 无 base_address,有 offset_address:movl %eax, (%ebx, %esi, 2),功能是将 eax 的值写入 ebx+esi*2 所指向的内存3. 有 base_address,无 offset_address:movl %eax, base_value(, %esi, 2),功能是将 eax 的值写入 base_value+esi*2 所指向的内存4. 有 base_address,有 offset_address:movl %eax, base_value(%ebx, %esi, 2),功能是将 eax 的值写入 base_value+ebx+esi*2 所指向的内存

基本内嵌汇编

  • 基本内嵌汇编是最简单的内嵌形式,其格式为:

asm [volatile] ("assembly code")

  • asm 和 __asm__ 是一样的,是由 gcc 定义的宏:#define __asm__ asm。同样常见的还有 volatile 和 __volatile__ 是一样的,是由 gcc 定义的宏:#define __volatile__ volatile
  • “assembly code”是咱们所写的汇编代码,它必须位于圆括号中,而且必须用双引号引起来。这是格 式要求,只要满足了这个格式 asm [volatile] (""),assembly code 甚至可以为空
  • 下面说下 assembly code 的规则
  • 指令必须用双引号引起来,无论双引号中是一条指令或多条指令
  • 一对双引号不能跨行,如果跨行需要在结尾用反斜杠'\'转义
  • 指令之间用分号';'、换行符'\n'或换行符加制表符'\n''\t'分隔
  • 提醒一下,即使是指令分布在多个双引号中,gcc 最终也要把它们合并到一起来处理,合并之后,指令间必须要有分隔符
asm("movl $9,%eax;""pushl %eax") 正确
asm("movl $9,%eax""pushl %eax")  错误

AT&T 编程实战

  • 前面的实验当中,我们已经做过了使用 Intel 汇编语法实现打印字符串,现在再用 AT&T 汇编语法实现打印“Hello world”吧
  • 创建 C 文件 hello.c,内容如下:
char * vstr = "Hello world\n";
int main()
{
    asm("                \
        movl $1, %ebx;   \
        movl vstr, %ecx; \
        movl $12, %edx;  \
        movl $4, %eax;   \
        int $0x80;       \
        movl $0, %ebx;   \
        movl $1, %eax;   \
        int $0x80        \
    ");
    return 0;
}
  • 编译

gcc hello.c

  • 报错
/usr/bin/ld: /tmp/ccd0c0Vm.o: relocation R_X86_64_32S against symbol `vstr' can not be used when making a PIE object; recompile with -fPIE
collect2: error: ld returned 1 exit status
  • 按照提示加 -fPIE

gcc -fPIE hello.c

  • 结果还是提示相同的错误,经过反复摸索,最终解决办法:加 -no-pie

gcc -no-pie hello.c

  • 运行,成功打印出 “Hello world”

扩展内嵌汇编

  • 由于基本内联汇编功能太薄弱了,所以才对它进行了扩展以使其功能强大
  • 扩展内嵌汇编的格式:

asm [volatile] (“assembly code” : output : input : clobber/modify)

  • 和前面的基本内联汇编相比,扩展内联汇编在圆括号中变成了 4 部分,多了 output、input 和 clobber/modify 三项。其中的每一部分都可以省略,甚至包括 assembly code。省略的部分要保留冒号分隔符来占位,如果省略的是后面的一个或多个连续的部分,分隔符也不用保留,比如省略了 clobber/modify,不需要保留 input 后面的冒号
  • output: 用来指定汇编代码的数据如何输出给 C 代码使用
  • input: 用来指定 C 中数据如何输入给汇编使用
  • clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来,至于怎么保护咱不管,其实就使用前入栈,使用后出栈恢复,就像汇编函数的入栈和出栈,想一下我们内嵌汇编前是不是入栈保存,内嵌汇编执行后是不是也没有出栈恢复操作?这里其实就是显性的通知 gcc 编译器让 gcc 自动添加入栈和出栈操作
  • 直接用一个实际例子体会一下扩展内嵌汇编 C 语言变量与汇编寄存器中间的数据传输
void main() { 
    int in_a = 1, in_b = 2, out_sum;
    asm("                      \
        addl %%ebx, %%eax"     \
        : "=a"(out_sum)        \
        : "a"(in_a),"b"(in_b)  \
      );
      // 等价于:
      // eax = in_a
      // ebx = in_b
      // eax = eax + ebx
      // out_sum = eax
}
  • 在基本内联汇编中的寄存器用单个 % 做前缀,在扩展内联汇编中,单个 % 有了新的用途,用来表示占位符(一会儿细讲),所以在扩展内联汇编中的寄存器前面用两个 % 做前缀
  • output/input 格式:

"寄存器约束名"(C 变量名)

  • 寄存器约束名是要求 gcc 使用哪个寄存器的,常见的约束有
  • a:表示寄存器 eax/ax/al
  • b:表示寄存器 ebx/bx/bl
  • c:表示寄存器 ecx/cx/cl
  • d:表示寄存器 edx/dx/dl
  • D:表示寄存器 edi/di
  • S:表示寄存器 esi/si
  • q:表示任意这 4 个通用寄存器之一:eax/ebx/ecx/edx
  • r:表示任意这 6 个通用寄存器之一:eax/ebx/ecx/edx/esi/edi
  • g:表示可以存放到任意地点(寄存器和内存)。相当于除了同 q 一样外,还可以让 gcc 安排在内存中
  • A:把 eax 和 edx 组合成 64 位整数
  • f:表示浮点寄存器
  • t:表示第 1 个浮点寄存器
  • u:表示第 2 个浮点寄存器
  • output 部分:"=a"(out_sum) 表示把寄存器 eax/ax/al 寄存器的值传递给 out_sum 变量中,就是 out_sum = eax 的意思
  • input 部分:"a"(in_a),"b"(in_b) 表示 eax = in_a, ebx = in_b
  • 好了,内嵌汇编暂时就了解这么多吧
目录
相关文章
|
2月前
|
数据处理 调度 数据格式
源程序的编程理解是怎样的
从编译程序(或抽象)的视角理解源程序
|
3月前
|
C语言
【汇编语言实战】基础知识+函数的引用(求1+2+..+N)+OllyDBG的使用
【汇编语言实战】基础知识+函数的引用(求1+2+..+N)+OllyDBG的使用
18 1
|
10月前
|
IDE 编译器 开发工具
[笔记]vs2015 编写汇编masm32之使用MASM32库
[笔记]vs2015 编写汇编masm32之使用MASM32库
|
编译器 C语言 数据安全/隐私保护
汇编语言和本地代码及通过编译器输出汇编语言的源代码
汇编语言和本地代码及通过编译器输出汇编语言的源代码
90 0
【8086汇编】《汇编语言(第三版)》实验一
需要用到的指令✨✨ 查看、修改CPU中寄存器的内容:R命令 查看内存中的内容:D命令 修改内存中的内容:E命令(可以写入数据、指令,在内存中,它们实际上没有区别) 将内存中的内容解释为机器指令和对应的汇编指令:U命令 执行CS:IP指向的内存单元处的指令:T命令 以汇编指令的形式向内存中写入指令:A命令
|
存储 机器学习/深度学习 数据处理
【8086汇编】基础知识篇
前言:什么是汇编 ​   汇编语言是很多相关课程(如数据结构、操作系统、微机原理等)的重要基础。其实仅从课程关系的角度讨论汇编语言的重要性未免片面,概括地说,如果你想从事计算机科学方面的工作的话,汇编语言的基础是必不可缺的。原因很简单,我们的工作平台、研究对象都是机器,汇编语言是人和计算机沟通的最直接的方式,它描述了机器最终所要执行的指令序列。想深入研究英国文化,不会英语行吗?汇编语言是和具体的微处理器相联系的,每一种微处理器的汇编语言都不一样,只能通过一种常用的、结构简洁的微处理器的汇编语言来进行学习,从而达到学习汇编的两个最根本的目的:充分获得底层编程的体验,深刻理解机器运行程序的机理。
|
存储 JavaScript Java
Win32汇编:汇编基本知识总结
汇编语言是所有程序设计语言中最古老的,它与计算机机器语言最为接近,通过汇编语言可以直接访问计算机的硬件,能够直接与CPU对话,可以说汇编语言是所有编程语言中语法格式最自由的,但自由的代价就是需要了解计算机体系结构和操作系统的大量细节,每编写一段程序都需要考虑各种硬件的状态,从而导致使用汇编写程序效率非常低.
329 0
|
Java 编译器 前端开发
烂尾工程: Java实现的汇编语言编译器
一个半拉子工程, 用Java实现的汇编语言编译器的介绍. 代码中使用中文命名. An unfinished project, an assembler implemented in Java, with naming in Chinese.
1149 0