这个没有使用libc的C程序是如何工作的?

27

我发现了一个不使用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和一种特殊的系统调用编码方式,通过创建全局标签来实现值的累加并节省字节:

.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开始并从C文件中调用main函数?

此外,独立的httpd.asm自定义二进制文件是如何工作的?只是手动优化汇编,将C源码和启动汇编组合起来吗?


1
顺便提一下,用 clang -Oz 编译,我把 .c + .S 版本压缩到了 992 字节。请参见我的答案顶部。 - Peter Cordes
2个回答

13
我克隆了repo并调整了.c和.S文件,以便使用clang -Oz更好地编译:从原始的gcc 1208字节减少到992字节。请参见我的分支中的WIP-clang-tuning branch,直到我清理并发送拉取请求为止。使用clang,系统调用的内联汇编在整体上确实可以节省空间,特别是一旦main没有调用和返回。我不知道是否想手动高尔夫整个从编译器输出重新生成的.asm文件;肯定有一些块可以显著节省,例如在循环中使用lodsb
看起来在调用这些标签之前,他们需要将r9设置为0,可以使用寄存器全局变量或者gcc -ffixed-r9告诉GCC永久保留该寄存器。否则,GCC会像其他寄存器一样,在r9中留下任何垃圾。
他们的函数声明为普通原型,而不是带有虚拟0参数的6个参数,以使每个调用站点实际上将r9清零,因此它们并非通过这种方式实现。
特殊的系统调用编码方式,我不会把它描述为“编码系统调用”。也许是“定义系统调用包装函数”。他们为每个系统调用定义自己的包装函数,以一种优化的方式,通过一个通用处理程序在底部落实。在C编译器的汇编输出中,你仍然会看到call write。在最终二进制文件中使用内联asm让编译器内联一个带有正确寄存器参数的syscall指令可能更紧凑,而不是让它看起来像一个普通的函数,并破坏所有的呼叫占用寄存器。特别是如果使用clang -Oz编译,它将使用3字节的push 2 / pop rax代替5字节的mov eax,2来设置调用号。push imm8/pop/syscall与call rel32的大小相同。
是的,你可以使用.global foo / foo:在手写汇编中定义函数。您可以将此视为具有多个不同系统调用入口点的大型函数。在汇编语言中,执行始终会传递到下一条指令,而不考虑标签,除非您使用跳转/调用/返回指令。CPU不知道标签。

因此,这就像C switch(){}语句一样,没有case:标签之间的break;,或者像C标签一样,您可以使用goto跳转到它们。当然,在汇编语言中,您可以在全局范围内执行此操作,而在C语言中,您只能在函数内部使用goto。并且在汇编语言中,您可以使用call而不仅仅是gotojmp)。

    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 foo而直接穿透。如果编译器足够聪明,像这样的C代码确实可以编译成手写汇编代码。 (并且您可以解决参数类型问题){{注意保留占位符}}
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...);
}

args...是传递参数的寄存器(RDI,RSI,RDX,RCX,R8),它们保持不变。对于x86-64 System V,R9是最后一个传递参数的寄存器,但他们没有使用任何需要6个参数的系统调用。 setsockopt需要5个参数,因此他们无法跳过mov r10, rcx。但是,他们能够将r9用于其他事情,而不需要将其作为第6个参数传递。


他们为了节省字节而牺牲性能,使用xor rbp,rbp 而不是 xor ebp,ebp,这很有趣。除非使用gcc -Wa,-Os start.S编译,否则GAS不会为你优化REX前缀。(GCC是否优化汇编源文件?)
他们可以使用xchg rax, r9(包括REX在内的2个字节)来节省另一个字节,而不是使用mov rax, r9(REX + opcode + modrm)。(x86机器码的Code Golf.SE技巧)
我也会使用xchg eax, r9d,因为我知道Linux系统调用号适合32位,尽管它不会节省代码大小,因为仍然需要REX前缀来编码r9d寄存器编号。此外,在只需要加1的情况下,inc r9d只有3个字节,而add r9d, 1有4个字节(REX + opcode + modrm + imm8)。(inc的无modrm短格式编码仅在32位模式下可用;在64位模式下,它被重新用作REX前缀。) mov rsi,rsp也可以节省一个字节,如push rsp / pop rsi(每个1个字节),而不是3个字节的REX + mov。这将为在call exit之前使用xchg edi, eax返回main的返回值腾出空间。
但是,由于他们没有使用libc,因此可以内联该exit,或将系统调用放置在_start下面,以便它们可以直接进入其中,因为exit恰好是最高编号的系统调用!或者至少使用jmp exit,因为它们不需要堆栈对齐,jmp rel8call rel32更紧凑。
另外,独立的httpd.asm自定义二进制文件是如何工作的?只是手写优化汇编代码结合C源代码和起始汇编代码吗?
不是,它是完全独立的,包含了start.S代码(?_017:标签处),也许还包含手动调整的编译器输出。可能来自于链接可执行文件的反汇编手动调整,因此即使是手写的汇编部分,也没有漂亮的标签名称。 (具体来说,来自于Agner Fog的objconv, 它使用该格式来标记它的NASM语法反汇编.)
(Ruslan 还指出像 cmp 之后的 jnz,而不是对人类有更适当的语义意义的 jne,因此另一个迹象是编译器输出,而不是手写的。)
我不知道他们如何安排编译器不去触碰r9。这似乎只是运气。自述文件表明,对于他们的GCC版本,仅编译.c和.S就可以工作。
至于ELF头文件,请参见文件顶部的注释,其中链接 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux - 您需要用nasm -fbin来汇编它,输出是一个完整的ELF二进制文件,可以直接运行。而不是需要链接+剥离的.o,因此您需要考虑文件中的每个字节。

2
在 mediocrevegetable1 找到的 httpd.asm 文件中,系统调用位于 _start 以下,它可以直接跳转到 _exit,在那里被称为 ?_017,但是在 ?_017 标签之前有一个 call ?_017 指令。看起来他们只是依赖于 GCC 不使用 R9,并希望在手动调整过程中进行验证。 - Ross Ridge
一般来说,如果使用32位abi,代码会更小吗?我想在一段时间前写我的代码高尔夫答案时总是使用这种方式。也许这应该成为x86高尔夫技巧页面上的一个答案。 - qwr
@qwr:也许吧,特别是对于编译器生成的代码来说,如果不小心避免使用 REX 前缀的话。 - Peter Cordes
难道不是只需放弃函数原型就能节省20多个字节吗?如果您没有指定它们的规范,那么您可以传递一个额外的参数...然后增量机制可以别名,因为它只计算参数。 - l.k
@l.k:我完全不明白你在说什么。如果没有原型,每个函数调用都需要将EAX清零,因为它通过XMM寄存器传递了0个参数。其他所有内容都是相同的,除了隐式的“int”返回值。(但它们都返回有符号整数,就像“ssize_t”被定义的那样)。如果你想让每个调用点显式地清零r8或r9,那么与现在发生的相比,这将导致更多的额外代码大小。 - Peter Cordes
我一直在想有时候看到的那个调用者清除eax是怎么回事。这很有道理,谢谢。 有点惊讶我从来没有读过,但说实话,我倾向于忽略浮点数。 - l.k

6

你的理解基本正确。非常有趣,我以前从未见过这样的东西。但基本上如你所说,每次调用标签时,r9 会不断累加,直到达到系统调用号为 0 的 read 标签。这就是为什么顺序相当巧妙。假设在调用 read 之前 r9 为 0(read 标签本身在调用正确的系统调用之前将 r9 清零),则不需要添加操作,因为 r9 已经具有所需的正确系统调用号。 write 的系统调用号为 1,因此只需要从 0 加 1,这在宏调用中显示出来。 open 的系统调用号为 2,因此首先在 open 标签处加 1,然后在 write 标签处再次加 1,最后在 read 标签处将正确的系统调用号放入 rax 中。参数寄存器如 rdirsirdx 等也没有被更改,因此它基本上就像一个普通的函数调用。
我假设你在谈论this file。不确定具体情况,但看起来是手动创建了一个ELF文件,可能是为了进一步减小大小。另外,这个单独的httpd.asm自定义二进制文件是如何工作的?只是将C源代码和启动汇编组合的手动优化汇编吗?

3
看起来不像是从头手写的汇编代码,更像是手工调整的反汇编代码。首先,标签只是数字,而不是可读性强的名称。其次,出现了一些奇怪的助记符选择,例如在cmp之后使用jnz而不是jne - Ruslan
@Ruslan 确实,现在我想起来了。我想知道是什么产生了那个汇编代码,它看起来像 NASM 而不是 start.S 中似乎是 GAS,所以我想知道是什么产生了那个汇编代码。无论如何,我已经删除了帖子的最后一行。 - mediocrevegetable1
2
“?_033:” 标签看起来像 Agner Fog 的 “objconv” 风格(它支持 NASM 语法输出)。 - Ruslan
我这么说是因为我认为inc eax是一个1字节的指令,尽管我不太记得了。将eax清零只需要1个字节。 - qwr
@qwr 是的,但是可能会有很多系统调用不会返回0。例如,在调用“write”、“socket”等之后,您可能总是需要使用“xor eax,eax”。因此,将eax清零可能只需要1个字节,但需要清零的数量可能会很多。 - mediocrevegetable1
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接