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

qwr*_*qwr 27 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)
Run Code Online (Sandbox Code Playgroud)

基本的系统调用在 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);
Run Code Online (Sandbox Code Playgroud)

所以我猜魔术发生在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
Run Code Online (Sandbox Code Playgroud)

这种理解是否正确?GCC 使用start.S为系统调用定义的符号,然后程序启动_startmain从 C 文件调用?

单独的httpd.asm自定义二进制文件如何工作?只是结合 C 源代码和开始汇编的手工优化汇编?

Pet*_*des 12

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


它看起来像他们需要的r90 之前任何这些标签的呼叫,无论是与一个寄存器全局变量也许gcc -ffixed-r9告诉GCC到不应该插手该寄存器永久。否则 GCC 会在 中留下任何垃圾r9,就像其他寄存器一样。

他们的函数是用普通原型声明的,而不是 6 个带有虚拟0args 的 args 来使每个调用站点实际上为零r9,所以这不是他们的做法。


编码系统调用的特殊方式

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

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


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

所以它就像一个switch(){}没有标签break;之间的 C语句case:,或者就像你可以用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);
Run Code Online (Sandbox Code Playgroud)

或者如果你把它改写为一个尾调用链,我们甚至可以省略它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...);
}
Run Code Online (Sandbox Code Playgroud)

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


这是会哄他们正在努力地试图以牺牲性能为代价节省字节,但仍使用xor rbp,rbp代替xor ebp,ebp。除非他们用 构建gcc -Wa,-Os start.S,否则GAS 不会为你优化掉 REX 前缀。(GCC 是否优化汇编源文件?

他们可以用xchg rax, r9(2 个字节,包括 REX)而不是mov rax, r9(REX + opcode + modrm)保存另一个字节。(代码 Golf.SE x86 机器代码提示

我也使用过,xchg eax, r9d因为我知道 Linux 系统调用号适合 32 位,尽管它不会节省代码大小,因为仍然需要 REX 前缀来编码r9d寄存器号。此外,在他们只需要加 1 的情况下,inc r9d只有 3 个字节,而add r9d, 1不是 4 个字节(REX + 操作码 + modrm + imm8)。(no-modrm 短格式编码inc仅在 32 位模式下可用;在 64 位模式下,它被重新用作 REX 前缀。)

mov rsi,rsp也可以将一个字节保存为push rsp/ pop rsi(每个 1 个字节)而不是 3 字节的 REX + mov。这将为使用xchg edi, eaxbefore返回 main 的返回值腾出空间call exit

但是因为他们没有使用 libc,他们可以内联那个exit,或者把系统调用放在下面, _start这样他们就可以落入其中,因为它exit恰好是编号最高的系统调用!或者至少jmp exit因为它们不需要堆栈对齐,并且jmp rel8call rel32.


另外,单独的 httpd.asm 自定义二进制文件是如何工作的?只是结合 C 源代码和开始汇编的手工优化汇编?

不,这是完全独立的,包含 start.S 代码(?_017:标签处),并且可能是手动调整的编译器输出。 也许来自链接的可执行文件的手动反汇编,因此即使对于来自手写 asm 的部分也没有很好的标签名称。(具体来说,来自Agner Fog 的objconv,它在其 NASM 语法反汇编中使用该格式作为标签。)

(Ruslan 还指出了jnzafter 之类的东西cmp,而不是jne它对人类具有更合适的语义含义,因此它的另一个标志是编译器输出,而不是手写。)

我不知道他们是如何安排让编译器不碰r9. 看来只是运气。自述文件表明只编译 .c 和 .S 对他们有用,他们的 GCC 版本。

至于 ELF 标头,请参阅文件顶部的注释,该注释链接了创建真正适用于 Linux 的真正青少年 ELF 可执行文件的旋风教程- 您可以使用它进行组装nasm -fbin,输出是一个完整的 ELF 二进制文件,可以运行。 不是需要链接 + 剥离的 .o,因此您可以考虑文件中的每个字节。

  • 在 mediocrevegetable1 发现的“httpd.asm”文件中,系统调用位于“_start”下方,它可能会直接进入“_exit”,在那里称为“?_017”,但在“call ?_017”之前有一个“call ?_017”指令`?_017` 标签。他们似乎只是依赖 GCC 而不是使用 R9,并希望在手动调整期间验证这一点。 (2认同)

med*_*le1 6

你对正在发生的事情非常正确。非常有趣,我以前从未见过这样的事情。但基本上就像你说的,每次它调用标签时,就像你说的那样,r9不断累加,直到达到read,其系统调用号为 0。这就是为什么顺序非常聪明。假设r9read调用之前为 0 (在调用正确的系统调用之前read标签本身为零r9),则不需要添加,因为r9已经具有所需的正确系统调用号。write的syscall号是1,所以只需要从0开始加1就可以了,在宏调用中显示。open的系统调用号是 2,所以首先在open标签处加 1 ,然后在标签处加 1write标签,然后将正确的系统调用号放入raxread标签。等等。像rdirsirdx等参数寄存器也没有被触及,所以它基本上就像一个普通的函数调用。

另外,单独的 httpd.asm 自定义二进制文件是如何工作的?只是结合 C 源代码和开始汇编的手工优化汇编?

我假设你在谈论这个文件。不确定这里到底发生了什么,但看起来正在手动创建一个 ELF 文件,可能是为了进一步减小大小。

  • 看起来不像是从头开始手写的程序集。更像是手工调整的拆卸。首先,标签只是数字而不是可读的名称。然后,一些奇怪的助记符选择,例如“cmp”后面的“jnz”而不是“jne”。 (3认同)
  • `?_033:` 标签看起来像 Agner Fog 的 `objconv` 的风格(它支持 NASM 输出语法)。 (2认同)