【问题标题】:How does this C program without libc work?这个没有 libc 的 C 程序如何工作?
【发布时间】:2021-06-25 07:05:07
【问题描述】:

我遇到了一个没有 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 开始并从C 文件调用main

另外,单独的httpd.asm 自定义二进制文件是如何工作的?只是结合 C 源代码和启动程序集的手动优化程序集?

【问题讨论】:

  • 顺便说一句,使用clang -Oz,我将 .c + .S 版本缩减至 992 字节。请参阅我的答案的顶部。

标签: c assembly x86-64 system-calls abi


【解决方案1】:

(我克隆了 repo 并调整了 .c 和 .S 以使用 clang -Oz 更好地编译:992 字节,低于使用 gcc 的原始 1208 字节。请参阅我 fork 中的 WIP-clang-tuning branch,直到我开始清理启动并发送拉取请求。使用 clang,系统调用的内联 asm 确实 总体上节省了大小,尤其是当 main 没有调用和没有 rets 时。如果我想手动打高尔夫球,IDK @987654329 @ 从编译器输出重新生成后;肯定有一些可以显着节省的部分,例如在循环中使用 lodsb。)


看起来他们需要 r9 成为 0 调用这些标签中的任何一个之前,要么使用寄存器全局变量,要么可能是 gcc -ffixed-r9 to tell GCC to keep its hands off that register permanently。否则 GCC 会在 r9 中留下任何垃圾,就像其他寄存器一样。

他们的函数是用普通原型声明的,而不是用 6 个 args 和 dummy 0 args 让每个调用站点实际上为零 r9,所以他们不是这样做的。


编码系统调用的特殊方式

我不会将其描述为“编码系统调用”。也许“定义系统调用包装函数”。他们为每个系统调用定义了自己的包装函数,以一种优化的方式落入底部的一个公共处理程序中。在 C 编译器的 asm 输出中,您仍会看到 call write

(对于最终的二进制文件来说,使用内联汇编可能更紧凑,让编译器内联 syscall 指令,并将 args 放在正确的寄存器中,而不是让它看起来像一个破坏所有调用的普通函数-破坏寄存器。特别是如果使用 clang -Oz 编译,它将使用 3 字节 push 2 / pop rax 而不是 5 字节 mov eax, 2 来设置调用号。push imm8/pop/@987654345 @ 与 call rel32 大小相同。)


是的,您可以使用 .global foo / foo: 在手写 asm 中定义函数。 您可以将其视为一个大型函数,具有多个入口点,用于不同的系统调用。 在 asm 中,无论标签如何,执行总是传递到下一条指令,除非您使用 jump/call/ret 指令。 CPU 不知道标签。

所以它就像一个 C switch(){} 语句,在 case: 标签之间没有 break;,或者像 C 标签一样,您可以使用 goto 跳转到。当然,除了在 asm 中,您可以在全局范围内执行此操作,而在 C 中,您只能在函数内执行。在 asm 中,您可以使用 call 而不仅仅是 goto (jmp)。

    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 真的可以编译为手写 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...);
}

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


这很有趣,他们如此努力地以牺牲性能为代价来节省字节,但仍然使用xor rbp,rbp instead of xor ebp,ebp。除非他们使用 gcc -Wa,-Os start.S 构建,否则 GAS 不会为您优化掉 REX 前缀。 (Does GCC optimize assembly source file?)

他们可以使用xchg rax, r9(包括 REX 的 2 个字节)而不是 mov rax, r9(REX + opcode + modrm)保存另一个字节。 (Code golf.SE tips for x86 machine code)

我也会使用xchg eax, r9d,因为我知道 Linux 系统调用号适合 32 位,尽管它不会节省代码大小,因为仍然需要 REX 前缀来编码 r9d 寄存器号。此外,在他们只需要加 1 的情况下,inc r9d 只有 3 个字节,而add r9d, 1 是 4 个字节(REX + opcode + modrm + imm8)。 (inc 的 no-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 代码 (at the ?_017: label),可能还有手动调整的编译器输出。 可能来自链接可执行文件的手动反汇编,因此即使对于来自手写 asm 的部分也没有漂亮的标签名称。 (具体来说,来自Agner Fog's objconv,它在其 NASM 语法反汇编中使用该格式作为标签。)

(Ruslan 在cmp 之后还指出了jnz 之类的东西,而不是jne,后者具有更适合人类的语义含义,因此另一个迹象表明它是编译器输出,而不是手写。)

我不知道他们如何安排让编译器不要碰r9。似乎只是运气。自述文件表明只需编译 .c 和 .S 即可使用它们的 GCC 版本。

至于 ELF 标头,请参见文件顶部的注释,该注释链接 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux - 您将 将其与 nasm -fbin 组合起来,输出是一个完整的 ELF 二进制文件,可以运行. 不是需要链接 + 剥离的 .o,因此您可以考虑文件中的每个字节。

【讨论】:

  • 在 mediocrevegetable1 找到的httpd.asm 文件中,系统调用低于_start,它可能会落入_exit,在那里称为?_017,但有一个call ?_017 指令就在?_017 标签之前。他们似乎只是依赖 GCC 而不使用 R9,并希望在他们的手动调整过程中验证这一点。
  • 一般来说,如果他们使用 32 位 abi,​​代码会更小吗?我想当我不久前写我的代码高尔夫答案时,这就是我一直使用的。也许这应该是 x86 高尔夫技巧页面上的答案。
  • @qwr: 也许吧,尤其是编译器生成的代码不小心尽可能避免使用 REX 前缀。
  • 仅删除函数原型就不会获得大约 20 多个字节吗?如果你在没有规范的情况下声明它们,你可以传递一个额外的参数......然后增量机制可以别名,因为它只计算参数。
  • @l.k:我完全不理解你所说的。如果没有原型,每个函数调用都需要将 EAX 归零,因为它在 XMM regs 中传递了 0 个参数。除了隐含的int 返回值之外,其他一切都是一样的。 (但无论如何,它们都返回有符号整数,ssize_t 的定义方式)。如果您希望每个调用站点将 r8 或 r9 显式归零,那么与现在发生的情况相比,这将是更多的额外代码大小。
【解决方案2】:

您对正在发生的事情非常正确。非常有趣,我以前从未见过这样的东西。但基本上如你所说,每次调用标签时,如你所说,r9 会不断累加,直到达到read,其系统调用号为 0。这就是该命令非常聪明的原因。假设在调用read 之前r9 为0(在调用正确的系统调用之前read 标签本身将r9 归零),则不需要添加,因为r9 已经具有所需的正确系统调用号。 write的系统调用号为1​​,所以只需要从0加1即可,如宏调用所示。 open的系统调用号是2,所以首先在open标签处加1,然后在write标签处再加1,然后将正确的系统调用号放入rax的@ 987654334@ 标签。等等。 rdirsirdx等参数寄存器也没有被触及,所以它基本上就像一个普通的函数调用。

另外,单独的 httpd.asm 自定义二进制文件是如何工作的?只是结合 C 源代码和启动程序集的手动优化程序集?

我假设您说的是this file。不确定这里到底发生了什么,但看起来像是手动创建了一个 ELF 文件,可能是为了进一步减小大小。

【讨论】:

  • 看起来不像是从头开始手写的程序集。更像是手动调整的拆卸。首先,标签只是数字而不是可读的名称。然后,在cmp 之后,一些奇怪的助记符选择,例如jnz 而不是jne
  • @Ruslan 是的,现在我想起来了。我想知道当时是什么产生了这个程序集,它看起来像 NASM,而不是 start.S 中的 GAS,所以我想知道是什么产生了它。无论如何,删除帖子的最后一行。
  • ?_033: 标签看起来像 Agner Fog 的 objconv 的样式(它确实支持 NASM 输出语法)。
  • @Ruslan 好吧,有道理。
  • 程序不能只增加 rax 而不是 r9 吗?不知道他们为什么使用 r9
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-08-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多