这个没有 libc 的 C 程序如何工作?
How does this C program without libc work?
我遇到了一个没有使用 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 源代码和开始汇编的手工优化汇编?
你对正在发生的事情几乎是正确的。非常有趣,我以前从未见过这样的东西。但基本上正如你所说,每次它调用标签时,如你所说,r9
一直累加直到达到 read
,其系统调用号为 0。这就是为什么顺序非常聪明的原因。假设在调用 read
之前 r9
为 0(在调用正确的系统调用之前 read
标签本身归零 r9
),不需要添加,因为 r9
已经有所需的正确系统调用号。 write
的syscall号是1,所以只需要从0开始加1,在宏调用中有体现。 open
的系统调用号是2,所以先在open
标签处加1,再在write
标签处加1,然后将正确的系统调用号放入rax
在 read
标签处。等等。 rdi
、rsi
、rdx
等参数寄存器也没有被触及,所以它基本上就像一个普通的函数调用。
Also how does the separate httpd.asm custom binary work? Just hand-optimized assembly combining the C source and start assembly?
我假设你在谈论 this file。不确定这里到底发生了什么,但看起来像是手动创建了一个 ELF 文件,可能是为了进一步减小大小。
(我克隆了 repo 并调整了 .c 和 .S 以使用 clang -Oz 更好地编译:992 字节,低于使用 gcc 的原始 1208。请参阅我的叉子中的 WIP-clang-tuning branch,直到我开始清理它并发送拉取请求。使用 clang,系统调用的内联 asm 确实 整体节省大小,尤其是当 main 没有调用也没有 rets 时。IDK 如果我想hand-golf 从编译器输出重新生成后的整个 .asm
;肯定有一些块可以显着节省,例如在循环中使用 lodsb
。)
看起来他们需要 r9
成为 0
在 调用这些标签中的任何一个之前,可以使用寄存器全局变量或者 gcc -ffixed-r9
to tell GCC to keep its hands off that register permanently。否则 GCC 会像其他寄存器一样在 r9
中留下任何垃圾。
他们的函数是用普通原型声明的,而不是 6 个带有虚拟 0
参数的参数,以使每个调用站点实际上为零 r9
,所以他们不是这样做的。
special way of encoding syscalls
我不会将其描述为“编码系统调用”。也许“定义 系统调用包装函数”。他们正在为每个系统调用定义自己的包装函数,以一种优化的方式落入底部的一个通用处理程序中。在 C 编译器的 asm 输出中,您仍然会看到 call write
.
(对于最终的二进制文件来说,使用内联 asm 让编译器将 syscall
指令与正确寄存器中的 args 内联,而不是让它看起来像一个正常的函数可能更紧凑破坏所有 call-clobbered 寄存器。特别是如果使用 clang -Oz
编译,它将使用 3 字节 push 2
/ pop rax
而不是 5 字节 mov eax, 2
来设置电话号码。push imm8
/pop
/syscall
与 call rel32
的大小相同。)
是的,您可以使用 .global foo
/ foo:
在 hand-written asm 中定义函数。 您可以将其视为一个大型函数,具有针对不同系统调用的多个入口点。在 asm 中,执行总是传递到下一条指令,而不管标签如何,除非您使用 jump/call/ret 操作说明。 CPU 不知道标签。
所以它就像一个 C switch(){}
语句,在 case:
标签之间没有 break;
,或者像你可以用 goto
跳转到的 C 标签。当然,除了在 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 确实可以编译为 hand-written 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...
是 arg-passing 寄存器(RDI、RSI、RDX、RCX、R8),它们只是保持不变。 R9 是 x86-64 System V 的最后一个 arg-passing 寄存器,但他们没有使用任何带 6 个参数的系统调用。 setsockopt
需要 5 个参数,所以他们不能跳过 mov r10, rcx
。但是他们能够将 r9 用于其他用途,而不是需要它来传递第 6 个参数。
有趣的是,他们如此努力地以牺牲性能为代价来节省字节,但仍然使用 xor rbp,rbp
instead of xor ebp,ebp
. Unless they build with gcc -Wa,-Os start.S
, GAS won't optimize away the REX prefix for you. (Does GCC optimize assembly source file?)
他们可以使用 xchg rax, r9
(2 个字节,包括 REX)而不是 mov rax, r9
(REX + 操作码 + 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 + 操作码 + modrm + imm8)。 (inc
的 no-modrm short-form 编码仅在 32 位模式下可用;在 64 位模式下,它被重新用作 REX 前缀。)
mov rsi,rsp
也可以将一个字节保存为 push rsp
/ pop rsi
(每个 1 个字节)而不是 3 个字节的 REX + mov。这将为在 call exit
.
之前使用 xchg edi, eax
returning main 的 return 值腾出空间
但由于他们不使用 libc,他们可以内联 exit
,或者将系统调用 放在 _start
下面,这样他们就可以落入它,因为 exit
恰好是 highest-numbered 系统调用!或者至少 jmp exit
因为它们不需要堆栈对齐,并且 jmp rel8
比 call rel32
.
更紧凑
Also how does the separate httpd.asm custom binary work? Just hand-optimized assembly combining the C source and start assembly?
不,这完全 stand-alone 合并了 start.S 代码(at the ?_017:
label), and maybe hand-tweaked compiler output. Perhaps from hand-tweaking disassembly of a linked executable, hence not having nice label names even for the part from the hand-written asm. (Specifically, from Agner Fog's objconv
,它在其 NASM-syntax 反汇编中使用标签格式。)
(Ruslan 还在 cmp
之后指出了 jnz
之类的东西,而不是 jne
,后者对人类具有更合适的语义,所以它的另一个标志是编译器输出,不是 hand-written.)
我不知道他们是如何安排让编译器不去碰 r9
。似乎只是运气。自述文件表明只需编译 .c 和 .S 就可以使用他们的 GCC 版本。
至于 ELF headers,请参阅文件顶部的注释,其中 links A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux - 你会 assemble 这与 nasm -fbin
和输出是一个完整的e ELF 二进制文件,准备好 运行. 不是您需要 link + 剥离的 .o,因此您可以考虑文件中的每个字节。
我遇到了一个没有使用 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 源代码和开始汇编的手工优化汇编?
你对正在发生的事情几乎是正确的。非常有趣,我以前从未见过这样的东西。但基本上正如你所说,每次它调用标签时,如你所说,r9
一直累加直到达到 read
,其系统调用号为 0。这就是为什么顺序非常聪明的原因。假设在调用 read
之前 r9
为 0(在调用正确的系统调用之前 read
标签本身归零 r9
),不需要添加,因为 r9
已经有所需的正确系统调用号。 write
的syscall号是1,所以只需要从0开始加1,在宏调用中有体现。 open
的系统调用号是2,所以先在open
标签处加1,再在write
标签处加1,然后将正确的系统调用号放入rax
在 read
标签处。等等。 rdi
、rsi
、rdx
等参数寄存器也没有被触及,所以它基本上就像一个普通的函数调用。
Also how does the separate httpd.asm custom binary work? Just hand-optimized assembly combining the C source and start assembly?
我假设你在谈论 this file。不确定这里到底发生了什么,但看起来像是手动创建了一个 ELF 文件,可能是为了进一步减小大小。
(我克隆了 repo 并调整了 .c 和 .S 以使用 clang -Oz 更好地编译:992 字节,低于使用 gcc 的原始 1208。请参阅我的叉子中的 WIP-clang-tuning branch,直到我开始清理它并发送拉取请求。使用 clang,系统调用的内联 asm 确实 整体节省大小,尤其是当 main 没有调用也没有 rets 时。IDK 如果我想hand-golf 从编译器输出重新生成后的整个 .asm
;肯定有一些块可以显着节省,例如在循环中使用 lodsb
。)
看起来他们需要 r9
成为 0
在 调用这些标签中的任何一个之前,可以使用寄存器全局变量或者 gcc -ffixed-r9
to tell GCC to keep its hands off that register permanently。否则 GCC 会像其他寄存器一样在 r9
中留下任何垃圾。
他们的函数是用普通原型声明的,而不是 6 个带有虚拟 0
参数的参数,以使每个调用站点实际上为零 r9
,所以他们不是这样做的。
special way of encoding syscalls
我不会将其描述为“编码系统调用”。也许“定义 系统调用包装函数”。他们正在为每个系统调用定义自己的包装函数,以一种优化的方式落入底部的一个通用处理程序中。在 C 编译器的 asm 输出中,您仍然会看到 call write
.
(对于最终的二进制文件来说,使用内联 asm 让编译器将 syscall
指令与正确寄存器中的 args 内联,而不是让它看起来像一个正常的函数可能更紧凑破坏所有 call-clobbered 寄存器。特别是如果使用 clang -Oz
编译,它将使用 3 字节 push 2
/ pop rax
而不是 5 字节 mov eax, 2
来设置电话号码。push imm8
/pop
/syscall
与 call rel32
的大小相同。)
是的,您可以使用 .global foo
/ foo:
在 hand-written asm 中定义函数。 您可以将其视为一个大型函数,具有针对不同系统调用的多个入口点。在 asm 中,执行总是传递到下一条指令,而不管标签如何,除非您使用 jump/call/ret 操作说明。 CPU 不知道标签。
所以它就像一个 C switch(){}
语句,在 case:
标签之间没有 break;
,或者像你可以用 goto
跳转到的 C 标签。当然,除了在 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 确实可以编译为 hand-written 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...
是 arg-passing 寄存器(RDI、RSI、RDX、RCX、R8),它们只是保持不变。 R9 是 x86-64 System V 的最后一个 arg-passing 寄存器,但他们没有使用任何带 6 个参数的系统调用。 setsockopt
需要 5 个参数,所以他们不能跳过 mov r10, rcx
。但是他们能够将 r9 用于其他用途,而不是需要它来传递第 6 个参数。
有趣的是,他们如此努力地以牺牲性能为代价来节省字节,但仍然使用 xor rbp,rbp
instead of xor ebp,ebp
. Unless they build with gcc -Wa,-Os start.S
, GAS won't optimize away the REX prefix for you. (Does GCC optimize assembly source file?)
他们可以使用 xchg rax, r9
(2 个字节,包括 REX)而不是 mov rax, r9
(REX + 操作码 + 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 + 操作码 + modrm + imm8)。 (inc
的 no-modrm short-form 编码仅在 32 位模式下可用;在 64 位模式下,它被重新用作 REX 前缀。)
mov rsi,rsp
也可以将一个字节保存为 push rsp
/ pop rsi
(每个 1 个字节)而不是 3 个字节的 REX + mov。这将为在 call exit
.
xchg edi, eax
returning main 的 return 值腾出空间
但由于他们不使用 libc,他们可以内联 exit
,或者将系统调用 放在 _start
下面,这样他们就可以落入它,因为 exit
恰好是 highest-numbered 系统调用!或者至少 jmp exit
因为它们不需要堆栈对齐,并且 jmp rel8
比 call rel32
.
Also how does the separate httpd.asm custom binary work? Just hand-optimized assembly combining the C source and start assembly?
不,这完全 stand-alone 合并了 start.S 代码(at the ?_017:
label), and maybe hand-tweaked compiler output. Perhaps from hand-tweaking disassembly of a linked executable, hence not having nice label names even for the part from the hand-written asm. (Specifically, from Agner Fog's objconv
,它在其 NASM-syntax 反汇编中使用标签格式。)
(Ruslan 还在 cmp
之后指出了 jnz
之类的东西,而不是 jne
,后者对人类具有更合适的语义,所以它的另一个标志是编译器输出,不是 hand-written.)
我不知道他们是如何安排让编译器不去碰 r9
。似乎只是运气。自述文件表明只需编译 .c 和 .S 就可以使用他们的 GCC 版本。
至于 ELF headers,请参阅文件顶部的注释,其中 links A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux - 你会 assemble 这与 nasm -fbin
和输出是一个完整的e ELF 二进制文件,准备好 运行. 不是您需要 link + 剥离的 .o,因此您可以考虑文件中的每个字节。