c - 这个没有 libc 的 C 程序如何工作?

标签 c assembly x86-64 system-calls abi

我遇到了一个没有 libc 的最小 HTTP 服务器:https://github.com/Francesco149/nolibc-httpd
我可以看到定义了基本的字符串处理函数,导致 write系统调用:

#define fprint(fd, s) write(fd, s, strlen(s))
#define fprintn(fd, s, n) write(fd, s, n)
#define fprintl(fd, s) fprintn(fd, s, sizeof(s) - 1)
#define fprintln(fd, s) fprintl(fd, s "\n")
#define print(s) fprint(1, s)
#define printn(s, n) fprintn(1, s, n)
#define printl(s) fprintl(1, s)
#define println(s) fprintln(1, s)
基本的系统调用在 C 文件中声明:
size_t read(int fd, void *buf, size_t nbyte);
ssize_t write(int fd, const void *buf, size_t nbyte);
int open(const char *path, int flags);
int close(int fd);
int socket(int domain, int type, int protocol);
int accept(int socket, sockaddr_in_t *restrict address,
           socklen_t *restrict address_len);
int shutdown(int socket, int how);
int bind(int socket, const sockaddr_in_t *address, socklen_t address_len);
int listen(int socket, int backlog);
int setsockopt(int socket, int level, int option_name, const void *option_value,
               socklen_t option_len);
int fork();
void exit(int status);
所以我猜魔术发生在 start.S ,其中包含 _start以及一种通过创建全局标签来编码系统调用的特殊方式,这些标签通过并在 r9 中累积值以节省字节:
.intel_syntax noprefix

/* functions: rdi, rsi, rdx, rcx, r8, r9 */
/*  syscalls: rdi, rsi, rdx, r10, r8, r9 */
/*                           ^^^         */
/* stack grows from a high address to a low address */

#define c(x, n) \
.global x; \
x:; \
  add r9,n

c(exit, 3)       /* 60 */
c(fork, 3)       /* 57 */
c(setsockopt, 4) /* 54 */
c(listen, 1)     /* 50 */
c(bind, 1)       /* 49 */
c(shutdown, 5)   /* 48 */
c(accept, 2)     /* 43 */
c(socket, 38)    /* 41 */
c(close, 1)      /* 03 */
c(open, 1)       /* 02 */
c(write, 1)      /* 01 */
.global read     /* 00 */
read:
  mov r10,rcx
  mov rax,r9
  xor r9,r9
  syscall
  ret

.global _start
_start:
  xor rbp,rbp
  xor r9,r9
  pop rdi     /* argc */
  mov rsi,rsp /* argv */
  call main
  call exit
这种理解是否正确? GCC 使用 start.S 中定义的符号对于系统调用,程序从 _start 开始并调用 main从 C 文件?
还有怎么分开httpd.asm自定义二进制工作?只是结合 C 源代码和开始汇编的手工优化汇编?

最佳答案

(我克隆了 repo 并调整了 .c 和 .S 以使用 clang -Oz: 992 字节更好地编译,从使用 gcc 的原始 1208 字节减少。在我的 fork 中查看 WIP-clang-tuning branch,直到我开始清理它并发送一个pull request可以节省大量成本,例如在循环中使用 .asm。)

看起来他们需要 lodsb 在调用这些标签中的任何一个之前成为 r9 ,或者使用寄存器全局 var 或者 0 to tell GCC to keep its hands off that register permanently 。否则 GCC 会在 gcc -ffixed-r9 中留下任何垃圾,就像其他寄存器一样。
他们的函数是用普通原型(prototype)声明的,而不是 6 个带有虚拟 r9 args 的 args 来让每个调用站点实际上为零 0 ,所以这不是他们这样做的方式。

special way of encoding syscalls


我不会将其描述为“编码系统调用”。也许“定义系统调用包装函数”。他们正在为每个系统调用定义自己的包装函数,以一种优化的方式进入底部的一个公共(public)处理程序。在 C 编译器的 asm 输出中,您仍会看到 r9
(对于最终的二进制文件来说,使用内联 asm 让编译器将 call write 指令与正确寄存器中的 args 内联可能更紧凑,而不是让它看起来像一个破坏所有调用破坏寄存器的普通函数。特别是如果使用 clang syscall 编译,它将使用 3 字节 -Oz/push 2 而不是 5 字节 pop rax 来设置索书号。mov eax, 2/push imm8/popsyscall 的大小相同。)

是的,您可以使用 call rel32/.global foo 在手写 asm 中定义函数。 您可以将其视为一个大型函数,其中包含针对不同系统调用的多个入口点。 在 asm 中,执行总是传递到下一条指令,无论标签如何,除非您使用 jump/call/ret 指令。 CPU 不知道标签。
所以它就像一个在 foo: 标签之间没有 switch(){} 的 C break; 语句,或者像你可以用 case: 跳转到的 C 标签。当然,除了在 asm 中,您可以在全局范围内执行此操作,而在 C 中,您只能转到函数内。在 asm 中,您可以使用 goto 而不是 call ( goto )。
    static long callnum = 0;     // r9 = 0  before a call to any of these

    ...
    socket:
       callnum += 38;
    close:
       callnum++;         // can use inc instead of add 1
    open:                 // missed optimization in their asm
       callnum++;
    write:
       callnum++;
    read:
       tmp=callnum;
       callnum=0;
       retval = syscall(tmp, args);
或者如果你把它改写成一个尾调用链,我们甚至可以省略 jmp 而是直接失败:如果你有一个足够聪明的编译器,像这样的 C 真的可以编译成手写的 asm。 (你可以解决 arg-type
register long callnum asm("r9");     // GCC extension

long open(args...) {
   callnum++;
   return write(args...);
}
long write(args...) {
   callnum++;
   return read(args...); // tailcall
}
long read(args...){
       tmp=callnum;
       callnum=0;            // reset callnum for next call
       return syscall(tmp, args...);
}
jmp foo 是参数传递寄存器(RDI、RSI、RDX、RCX、R8),它们只是保持不变。 R9 是 x86-64 System V 的最后一个 arg-passing 寄存器,但他们没有使用任何需要 6 个 args 的系统调用。 args... 需要 5 个参数,所以他们不能跳过 setsockopt 。但是他们能够将 r9 用于其他用途,而不是需要它来传递第 6 个参数。

有趣的是,他们如此努力地以牺牲性能为代价来节省字节,但仍然使用 mov r10, rcx instead of xor rbp,rbp 。除非他们使用 xor ebp,ebp 构建,否则 GAS 不会为你优化掉 REX 前缀。 ( Does GCC optimize assembly source file? )
他们可以使用 gcc -Wa,-Os start.S(2 个字节,包括 REX)而不是 xchg rax, r9(REX + opcode + modrm)保存另一个字节。 ( Code golf.SE tips for x86 machine code )
我也使用过 mov rax, r9,因为我知道 Linux 系统调用号适合 32 位,尽管它不会节省代码大小,因为仍然需要 REX 前缀来编码 xchg eax, r9d 寄存器号。此外,在他们只需要加 1 的情况下,r9d 只有 3 个字节,而 inc r9d 是 4 个字节(REX + opcode + modrm + imm8)。 (add r9d, 1 的 no-modrm 短格式编码仅在 32 位模式下可用;在 64 位模式下,它被重新用作 REX 前缀。)inc 还可以将一个字节保存为 mov rsi,rsp/push rsp(每个 1 个字节),而不是 3 字节的 REX + mov。这将为在 pop rsi 之前使用 xchg edi, eax 返回 main 的返回值腾出空间。
但是由于他们没有使用 libc,他们可以内联 call exit ,或者将系统调用放在 exit 之下,这样他们就可以落入其中,因为 _start 恰好是编号最高的系统调用!或者至少是 exit 因为它们不需要堆栈对齐,并且 jmp exitjmp rel8 更紧凑。

Also how does the separate httpd.asm custom binary work? Just hand-optimized assembly combining the C source and start assembly?


不,那是完全独立的结合 start.S 代码 ( at the call rel32 label ),并且可能是手动调整的编译器输出。 可能来自对链接的可执行文件 的手动调整反汇编,因此即使对于来自手写 asm 的部分也没有很好的标签名称。 (具体来说,来自 Agner Fog's ?_017: ,它在其 NASM 语法反汇编中使用该格式作为标签。)
(Ruslan 还在 objconv 之后指出了诸如 jnz 之类的东西,而不是 cmp ,后者对人类具有更合适的语义意义,因此它的另一个标志是编译器输出,而不是手写。)
我不知道他们是如何安排让编译器不接触 jne 的。看来只是运气。自述文件表明只编译 .c 和 .S 对他们有用,他们的 GCC 版本。
至于 ELF header ,请参阅文件顶部的注释,它链接 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux - 您需要 将其与 r9 组合在一起,输出是一个完整的 ELF 二进制文件,可以运行了。 不是需要链接 + 剥离的 .o,因此您可以考虑文件中的每个字节。

关于c - 这个没有 libc 的 C 程序如何工作?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/66851039/

相关文章:

c - 使用 snprinf() 我是否需要考虑每个连接字符串末尾的空字节?

c++ - 创建并链接到从旧 DLL 文件生成的 .lib 文件

assembly - ARM:为什么我需要在函数调用时压入/弹出两个寄存器?

c - 重新映射堆栈成功,但随后引发 SEGV

c - 未初始化的指针变量有什么用?

C 将文本文件中的 3 行放入 3 个数组中

objective-c - 我如何检查 Xcode 4 中的程序集?

assembly - 单声道 ASM 一代

assembly - WC vs WB内存? x86_64上的其他类型的内存?

linux - 如何在 GAS 汇编中使用 "repne scasb"?