ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

使用库函数 API 和 C 代码中嵌入汇编代码两种方式使用同一个系统调用

使用库函数 API 和 C 代码中嵌入汇编代码两种方式使用同一个系统调用 姓名李令琪原创作品转载请注明出处《Linux 内核分析》MOOC 课程 http://mooc.study.163.com/course/USTC-1000029000一、系统调用选择本实验我选择write其功能是向文件描述符对应的对象写入数据。在本实验采用的 Linux i386 系统调用 ABI 中write的系统调用号为4对应内核处理函数sys_write不属于作业排除的 13 号time。选取write的原因是它的三个参数分别包含整数、指针和长度能直接展示参数传递规则而且输出结果容易观察。库函数原型可写为ssize_twrite(intfd,constvoid*buf,size_tcount);fd为文件描述符buf为待写数据的起始地址count为请求写入的字节数。成功时返回实际写出的字节数失败时库函数返回-1并设置errno。成功不一定表示写出了全部请求数据一般程序还需处理短写本实验使用 16 字节的短消息重点观察系统调用机制。二、实验环境与截图本实验统一采用32 位 x86i386用户程序。64 位 x86 主机可以在支持 i386 程序运行且具备相应开发库的条件下进行实验编译时使用-m32。内核处理过程按教材介绍的 Linux 3.18 系列 x86-32 路径分析实验机当前的内核版本应按实际运行记录填写。项目实验记录操作系统及内核版本Linux 5.15.0-168-genericx86_64Ubuntu 实验环境见图 1GCC 版本GCC (Ubuntu 4.8.2-19ubuntu1) 4.8.2可执行文件格式ELF 32-bit LSB executableIntel 80386动态链接见图 4用户程序指针长度实际输出sizeof(void*)4见图 4如图记录了实验目录、实际内核信息与 GCC 版本图 4 进一步记录程序的 32 位 ELF 格式。三、实验代码与运行方法以下程序在同一个进程中先调用 libc 的write()再调用使用内嵌汇编实现的raw_write32()。两次调用使用相同的文件描述符、缓冲区和长度随后再分别传入无效描述符-1比较错误返回方式。/* Linux i386: compare libc write() with an inline-assembly system call. */#includeerrno.h#includestdio.h#includeunistd.h#if!defined(__i386__)#errorThis experiment uses the i386 ABI. Compile with gcc -m32.#endif/* Return the kernels raw result; deliberately do not change errno. */__attribute__((noinline))staticlongraw_write32(intfd,constvoid*buf,size_tcount){longret;__asm____volatile__(int $0x80:a(ret):0(4L),b(fd),c(buf),d(count):memory,cc);returnret;}intmain(void){constcharmsg[]Hello, syscall!\n;constsize_tlensizeof(msg)-1;ssize_tapi_ret;longasm_ret;intapi_errno,asm_errno;/* Keep diagnostic printf output in the intended order. */setvbuf(stdout,NULL,_IONBF,0);printf(ABI: i386; sizeof(void*)%zu; count%zu\n,sizeof(void*),len);printf([libc write]\n);api_retwrite(STDOUT_FILENO,msg,len);printf(return%ld\n,(long)api_ret);printf([inline asm write]\n);asm_retraw_write32(STDOUT_FILENO,msg,len);printf(raw return%ld\n,asm_ret);/* Invalid fd: compare libc error conversion with the raw result. */errno0;api_retwrite(-1,msg,len);api_errnoerrno;errno0;asm_retraw_write32(-1,msg,len);asm_errnoerrno;printf([invalid fd-1]\n);printf(libc: return%ld, errno%d\n,(long)api_ret,api_errno);printf(asm : raw return%ld, errno%d, decoded error%ld\n,asm_ret,asm_errno,asm_ret0?-asm_ret:0L);printf(EBADF%d\n,EBADF);return0;}编译和运行gcc-m32-O0-g-Wall-Wextra-fno-pie write_compare.c-owrite_comparefilewrite_compare ./write_compare本次使用 GCC 4.8.2 编译已按如图命令成功生成并运行 32 位程序。四、实验结果与两种方式的对照下列内容与本次实际运行结果一致ABI: i386; sizeof(void*)4; count16 [libc write] Hello, syscall! return16 [inline asm write] Hello, syscall! raw return16 [invalid fd-1] libc: return-1, errno9 asm : raw return-9, errno0, decoded error9 EBADF9消息Hello, syscall!\n包含 16 个字节。sizeof(msg)还包含 C 字符串结束标记\0所以程序用sizeof(msg)-1作为写入长度。本次实际运行中两次均返回 16说明两种方式都成功请求了相同的写操作。错误路径进一步揭示了库函数与系统调用的区别。对无效描述符Linux i386 内核的原始返回值为-EBADF即-9。libc 封装将该错误转换成函数返回-1并把errno设为EBADF。本实验的汇编函数直接交回eax的结果没有进行这种转换所以返回-9此前清零的用户态errno保持为 0。因此库函数 API 除了发起请求还负责向应用程序提供规定的错误接口。若要把原始汇编函数包装为类似 libc 的接口可以在检测到负错误码后设置errno并返回-1本实验保留原始结果以便比较。这里只讨论write不把“所有系统调用都能这样简单判断”作为一般结论。五、内嵌汇编的参数传递过程1. 系统调用 ABI 与普通 C 函数调用的区别在本实验的 i386int $0x80ABI 中eax用于传递系统调用号第 1 至第 6 个参数依次通过ebx、ecx、edx、esi、edi、ebp传递返回值通过eax交回。对应到这次write进入内核前的状态为内容寄存器本实验的成功调用系统调用号eax4第一个参数fdebx1即标准输出第二个参数bufecxmsg在用户空间的起始地址第三个参数countedx16系统调用结果返回后的eax本次成功调用为16无效描述符时为-9ecx中传递的是指针值并不是把整段字符串装进寄存器。内核随后依据该用户地址和长度访问数据。用户提供的地址仍然需要由内核按用户内存访问规则检查、使用放进寄存器不等于它自动成为可信的内核地址。普通的 i386 C 函数调用常按其调用约定从用户栈传入参数。进入内核的系统调用则遵循另一套 ABI并切换到内核栈。用户程序先在寄存器里准备约定的数据内核入口再保存和组织这些数据不能把一次普通的用户态call与系统调用的特权级切换混为一谈。2. 分析 GCC 扩展内联汇编核心代码为__asm____volatile__(int $0x80:a(ret):0(4L),b(fd),c(buf),d(count):memory,cc);这段代码使用约束把 C 变量绑定到指定寄存器由 GCC 生成装载寄存器的指令写法含义a(ret)输出操作数 0 来自eax将系统调用结果交给 C 变量ret。0(4L)输入与输出操作数 0 使用同一个位置调用前将系统调用号 4 放入eax。这里的0是操作数编号不是把数值 0 传给内核。b(fd)将第一个参数放入ebx。c(buf)将第二个参数的指针值放入ecx。d(count)将第三个参数放入edx。volatile表明这段汇编具有需要保留的副作用避免因返回值未使用等情况被优化删除。memory告诉编译器这里可能通过指针访问未逐一列出的内存使缓冲区准备与调用的关系得到正确处理。它是编译器约束不是 CPU 内存屏障指令。cc保守声明条件码可能受影响。eax已作为输出操作数描述不能再重复放进破坏列表。编译器也会处理普通 C 函数约定要求保存的寄存器例如在需要时保存和恢复ebx。模板只有一条int指令并不意味着寄存器没有被装载装载工作由输入约束产生的机器代码完成。3. 分析本次实际生成的反汇编本次执行以下命令提取raw_write32的反汇编objdump-d-Matt write_compare|sed-n/raw_write32:/,/^$/p图中函数从0x0804855d开始实际指令如下。地址随重新编译和链接可能变化此处记录本次截图中的地址。0804855d: push %ebp 0804855e: mov %esp,%ebp 08048560: push %ebx 08048561: sub $0x10,%esp 08048564: mov $0x4,%eax 08048569: mov 0x8(%ebp),%ebx 0804856c: mov 0xc(%ebp),%ecx 0804856f: mov 0x10(%ebp),%edx 08048572: int $0x80 08048574: mov %eax,-0x8(%ebp) 08048577: mov -0x8(%ebp),%eax 0804857a: add $0x10,%esp 0804857d: pop %ebx 0804857e: pop %ebp 0804857f: ret先看进入内核前的关键步骤指令地址指令作用0x08048564mov $0x4,%eax将 i386 的write调用号 4 放入eax。0x08048569mov 0x8(%ebp),%ebx从 C 函数栈帧取得第一个参数fd放入系统调用参数寄存器ebx。0x0804856cmov 0xc(%ebp),%ecx从栈帧取得第二个参数buf的指针值放入ecx。0x0804856fmov 0x10(%ebp),%edx从栈帧取得第三个参数count放入edx。0x08048572int $0x80按已经准备好的调用号和参数发起系统调用对应机器码为cd 80。这里的栈偏移反映了两套调用约定之间的转换。main按普通 i386 C 函数调用方式调用raw_write32(fd, buf, count)建立栈帧后0(%ebp)为保存的旧ebp4(%ebp)为 C 函数调用的返回地址三个参数依次位于8(%ebp)、12(%ebp)、16(%ebp)。GCC 随后把它们分别装入ebx/ecx/edx供系统调用 ABI 使用。本次成功调用在执行int前的参数应为ebx1、ecxmsg的地址、edx16。其中0x10(%ebp)表示“栈帧偏移 16 字节处的内容”不能直接解释为把常量 16 写入edx本次该参数的值为 16来自sizeof(msg)-1。系统调用返回后0x08048574的mov %eax,-0x8(%ebp)将原始结果保存到局部变量ret的栈位置。随后0x08048577再将ret装入eax作为普通 C 函数raw_write32的返回值。这对应源码中的a(ret)和return ret。函数开头的push %ebp与mov %esp,%ebp建立用户态 C 函数栈帧sub $0x10,%esp预留局部栈空间末尾对应恢复。ebx是此 C 函数调用约定要求被调用者保存的寄存器因此编译器在使用它传递系统调用参数前后生成push %ebx和pop %ebx。这些动作属于用户态函数的准备与收尾不是内核入口保存上下文的过程。因此这张反汇编截图清楚地展示了“C 栈参数 → 系统调用寄存器 →int $0x80→eax原始结果 → C 函数返回值”的完整用户态路径。六、从用户态进入内核并返回的过程结合教材第四章的参数传递规则与第五章对系统调用入口的分析本实验的汇编路径包含以下阶段。1. 用户程序准备请求raw_write32()按 ABI 准备调用号和三个参数。此时程序仍在用户态执行单纯把eax设为 4 并不会调用内核真正触发入口的是随后执行的int $0x80。2. CPU 根据中断向量进入受控入口0x80是中断向量号十进制为 1284才是这次请求的系统调用号两者承担不同作用。在教材所讨论的原生 32 位 Linux 路径中内核为 0x80 设置了允许用户态触发的入口。CPU 通过 IDT 中对应的描述符进入内核发生用户态到内核态的特权级切换并在切换内核栈时保存返回所需的用户态状态例如用户栈位置、标志和返回指令地址。随后开始执行system_call入口代码。CPU 的硬件入口动作与内核汇编保存通用寄存器的动作是两个环节。入口代码还要保存系统调用号及通用寄存器上下文以便分派请求和恢复执行。3. 内核分派具体系统调用system_call是多个系统调用共用的入口具体服务通过系统调用号区分。入口代码保存上下文、处理必要的入口工作并检查调用号在教材的 32 位分派路径中通过如下指令选择内核处理函数call *sys_call_table(,%eax,4)其中比例因子 4 来自32 位函数指针的字节数。当eax4时分派代码取出表中索引为 4 的函数指针也就是write对应的处理函数。这里的索引从 0 开始不是把中断向量 0x80 当作表项编号。参数最初由用户态寄存器传入内核入口保存寄存器后按内核的调用约定让sys_write取得参数。在教材所述的 i386 入口中这与保存到内核栈上的参数布局以及asmlinkage调用约定有关不应解释成所有内核 C 函数都直接沿用用户态寄存器调用约定。4. 内核执行写操作sys_write根据文件描述符找到当前进程对应的打开文件对象进行所需检查并通过 VFS 及目标对象的写入实现完成请求。标准输出通常关联终端也可能被 shell 重定向到文件或管道因此fd1表示标准输出描述符不能简单等同于某个固定硬件设备。字符串缓冲区仍在用户空间。内核按照用户内存访问规则读取其数据具体的读取或复制环节随目标对象及其写入实现而变化。无效文件描述符的对照调用无法取得合法的写入目标因此产生EBADF。5. 保存结果并恢复用户态内核处理函数返回后入口/返回代码将结果保存到将要恢复的寄存器上下文中使用户程序看到返回后的eax。返回用户态前还可能需要处理信号或重新调度等待处理工作并非每次系统调用都必然立即返回也并非每次都会切换进程。在本实验分析的int $0x80正常返回路径中内核恢复相应上下文通过中断返回机制回到用户态继续执行int后面的指令。GCC 再按a(ret)把结果交给 C 代码。6. 库函数方式与汇编方式的联系调用 libcwrite()时应用程序按普通 C 函数接口传入参数封装例程负责转换为系统调用约定、触发进入内核并处理返回结果。本实验的汇编函数由程序员直接指定 i386 寄存器和int $0x80省去了这部分封装。两条路径请求的是同一项内核服务但 libc 的底层入口指令可能随 libc 和运行环境变化例如使用快速系统调用入口。仅凭相同输出或strace中相同的调用名称不能断定两条路径都执行了int $0x80。如果在较新的 64 位内核上运行本实验的 32 位程序实际进入的是相应兼容路径内部符号和实现可能与教材的原生 32 位system_call不同。因此本文把本次用户程序的 i386 ABI 验证与教材版本的内核流程分析分别说明。七、可选的系统调用跟踪strace-etracewrite-otrace.txt ./write_comparecattrace.txt可在真实日志中查找两个写入Hello, syscall!\n的成功请求以及两个无效描述符的失败请求。由于printf也可能使用write输出说明文字日志中出现额外调用是正常的。特别需要注意strace 会把两次失败都格式化为-1 EBADF。这并不说明原始汇编结果变成了-1原始eax-9与跟踪工具面向读者显示的错误表示是不同层次。可以结合程序打印结果或用 GDB 在int $0x80前后观察寄存器进一步验证。以上给出可进一步验证的方法。本次已经完成的记录为图 1 至图 5未将 strace 或 GDB 跟踪写作已完成的实验。八、总结通过比较这两种调用方式我把系统调用理解为用户程序与内核之间一套受控的请求和返回协议。应用程序指定“需要什么服务”内核按照既定规则检查请求、使用资源并交回结果。库函数提供了便于编程的接口系统调用 ABI 规定了调用号、参数和结果的传递方式内核入口和具体处理函数共同完成受保护的服务。在本实验中eax4表达“请求 write”ebx/ecx/edx表达“向哪个描述符写、数据在哪里、写多少字节”int $0x80触发特权级切换。指针参数使有限的寄存器能够描述用户内存中的数据但内核仍必须检查和正确访问这些数据。返回后的eax则表示操作结果libc 在此基础上进一步提供-1与errno的错误约定。我也认识到进入内核态不等于创建新进程或必然切换到其他进程。系统调用首先是当前执行任务的权限与执行上下文变化阻塞、信号处理和调度可能影响何时返回。将寄存器传参、受控入口、内核分派、结果保存与上下文恢复联系起来才能完整理解一次系统调用而不只是记住某一条汇编指令。
返回列表