【问题标题】:Race condition when piping through x86-64 assembly program通过 x86-64 汇编程序管道时的竞争条件
【发布时间】:2021-07-12 15:15:49
【问题描述】:

我在汇编中编写了以下cat 的简化实现。它使用 linux 系统调用,因为我正在运行 linux。代码如下:

.section .data
.set MAX_READ_BYTES, 0xffff

.section .text
.globl _start

_start:
    movq (%rsp), %r10 # save the value of argc somewhere else
    movq 16(%rsp), %r9 # save the value of argv[1] somewhere else

    movl $12, %eax # syscall 12 is brk. see brk(2)
    xorq %rdi, %rdi # call with 0 as first arg to get current end of memory
    syscall
    movq %rax, %r8 # this is the address of the current end of memory

    leaq MAX_READ_BYTES(%rax), %rdi # let this be the new end of memory
    movl $12, %eax # syscall 12, brk
    syscall
    cmp %r8, %rax # compare the two; if the allocation failed, these will be equal
    je exit

    leaq -MAX_READ_BYTES(%rax), %r13 # store the start of the free area in %r13

    movq %r10, %rdi # retrieve the value of argc
    cmpq $0x01, %rdi # if there are no cli args, process stdin instead
    je stdin

    # open the file
    movl $0x02, %eax # syscall #2 = open.
    movq %r9, %rdi
    movl $0, %esi # second argument: flags. 0 means read-only.
    xorq %rdx, %rdx # this argument isn't used here, but zero it out for peace of mind.
    syscall # returns the file descriptor number in %rax
    movl %eax, %edi
    movl %edi, %r12d # first argument: file descriptor.
    call read_and_write
    jmp cleanup

stdin:
    movl $0x0000, %edi # first argument: file descriptor.
    movl %edi, %r12d # first argument: file descriptor.
    call read_and_write
    jmp cleanup

read_and_write:
    # read the file.
    movl $0, %eax # syscall #0 = read.
    movl %r12d, %edi
    movq %r13 /* pointer to allocated memory */, %rsi # second argument: address of a writeable buffer.
    movl $MAX_READ_BYTES, %edx # third argument: number of bytes to write.
    syscall # num bytes read in %rax
    movl %eax, %r15d

    # print the file
    movl $1, %eax # syscall #1 = write.
    movl $1, %edi # first argument: file descriptor. 1 is stdout.
    movq %r13, %rsi # second argument: address of data to write.
    movl %r15d, %edx # third argument: number of bytes to write.
    syscall # result ignored.
    cmpq $MAX_READ_BYTES, %r15
    je read_and_write
    ret

cleanup:
    # close the file
    movl $0x03, %eax # syscall #3 = close.
    movl %r14d, %edi # first arg: file descriptor number.
    syscall # result ignored.

exit:
    # set the exit code
    movl $60, %eax # syscall #60 = exit.
    movq $0, %rdi # exit 0 = success.
    syscall

我已经将它组装成一个名为asmcat 的ELF 二进制文件。为了测试这个程序,我得到了文件/tmp/random

$ wc -c /tmp/random
94870 /tmp/random

当我运行以下,结果是一致的:

$ ./asmcat /tmp/random | wc -c
94870

这是同一命令的两次单独运行:

$ cat /tmp/random | ./asmcat | wc -c
65536

$ cat /tmp/random | ./asmcat | wc -c
94870

将输出重定向到文件会始终生成相同大小的文件:

for i in {0..25}; do
    cat /tmp/random | ./asmcat > /tmp/asmcat-output-$i
done
for i in {0..25}; do
    wc -c /tmp/asmcat-output-$i
done

所有生成的文件都具有相同的大小,94870。这让我相信到wc 的管道是导致不一致行为的原因。我的程序应该做的就是一次读取标准输入,65535 个字节,然后写入标准输出。程序中可能存在错误,但是,为什么它会始终重定向到大小一致的文件?所以我强烈的感觉是管道的某些东西导致我的汇编程序输出大小的测量不一致。

欢迎任何反馈,包括在汇编程序中采用的方法(我只是为了好玩/练习而写的)。

【问题讨论】:

  • 您是否尝试在strace 下运行此程序以查看您正在进行的系统调用?看起来您遇到了单个写入管道的最大大小的限制。您的代码不会检查 write 的返回值以查看每次写入实际复制到输出 FD 的字节数。看起来它会在任何短读时退出,因此信号或 TTY 输入将是一个问题。此外,为了提高效率,最好将缓冲区设为 2 的幂,或者至少是页面大小 (4k) 的倍数。
  • 感谢@PeterCordes,这是很多很好的反馈。我以前从未使用过strace,但我使用gdb 一步一步地运行它。我现在就玩strace。当使用堆栈作为缓冲区时,决定从哪里开始的最佳实践是什么?我是否应该将 argc 和 argv 弹出到寄存器中,然后只使用 %rsp 作为缓冲区的起始地址?或者我应该使用某种偏移量?我稍后会回来根据您的建议编辑我的帖子以清理它。
  • @PeterCordes 奇怪的是,我在运行cat /tmp/random | strace ./asmcat | wc -c 时似乎无法重现不良行为。它总是以正确的字节数结束。
  • 是的,我在写答案时注意到了同样的事情。幸运的是,我能够解释原因:) 我最初的评论有一些不完全准确的猜测。

标签: linux assembly pipe x86-64 system-calls


【解决方案1】:

TL:DR:如果您的程序在 cat 可以重新填充管道缓冲区之前进行两次读取,
第二次读取仅获得 1 个字节。这会让你的程序决定提前退出。

这才是真正的错误。使这成为可能的其他设计选择是性能问题,而不是正确性。


您的程序在任何短读后停止(返回值小于请求的大小),而不是等待 EOF (read() == 0)。这是一种简化,有时对常规文件是安全的,但对其他任何文件都安全,尤其是对 TTY(终端输入)不安全,对管道或套接字也不安全。例如尝试运行./asmcat;它在你在一行上按回车后退出,而不是等待 control-D EOF。

Linux 管道缓冲区默认只有 64kiB (pipe(7) man page),比您正在使用的奇怪的奇数缓冲区大 1 个字节。在cat 的写入填满管道缓冲区后,您的 65535 字节读取会留下 1 个字节。如果您的程序在cat 可以再次写入之前赢得了与read 管道的竞争,则它只读取1 个字节。

不幸的是,在strace ./asmcat 下运行会使读取速度变慢,以至于无法观察到短读取,除非您还减慢cat 或任何其他程序以限制输入管道的写入端的速率。

用于测试的限速输入:

pv(1),管道查看器,很方便,它带有速率限制-L 选项和缓冲区大小限制,因此您可以确保其写入小于 64k。 (不经常进行较大的 64k 写入可能并不总是导致短读。)但如果我们只希望始终进行短读,则从终端以交互方式运行读取会更容易。 strace ./asmcat

$ pv -L8K -B16K /tmp/random | strace ./orig_asmcat | wc -c
execve("./orig_asmcat", ["./orig_asmcat"], 0x7ffcd441f750 /* 55 vars */) = 0
brk(NULL)                               = 0x61c000
brk(0x62bfff)                           = 0x62bfff
read(0, "=head1 NAME\n\n=for comment  Gener"..., 65535) = 819
write(1, "=head1 NAME\n\n=for comment  Gener"..., 819) = 819
close(0)                                = 0
exit(0)                                 = ?
+++ exited with 0 +++   # end of strace output
819                     # wc output
 819 B 0:00:00 [4.43KiB/s] [>              ]  0%        # pv's progress bar

对比修复了错误的asmcat,我们得到了预期的短读和相等大小的写序列。 (我的版本见下文)

execve("./asmcat", ["./asmcat"], 0x7ffd8c58f600 /* 55 vars */) = 0
read(0, "=head1 NAME\n\n=for comment  Gener"..., 65536) = 819
write(1, "=head1 NAME\n\n=for comment  Gener"..., 819) = 819
read(0, "check if a\nnamed variable exists"..., 65536) = 819
write(1, "check if a\nnamed variable exists"..., 819) = 819

代码审查

有多个浪费的指令,例如mov 写入一个您再也不会读取的寄存器,例如在调用之前设置 EDI,但随后函数调用将 R12D 作为 arg,而不是标准调用约定。

尽早读取 argc、argv 而不是将它们留在堆栈中直到需要它们,这同样是多余的。

.data 毫无意义:.set 是一个汇编时间常数。当你定义它时,当前部分是什么并不重要。你也可以把它写成MAX_READ_BYTES = 0xffff,更自然的汇编时常量语法。

可以在堆栈上分配缓冲区而不是使用 brk(它只有 64K - 1,x86-64 Linux 默认允许 8MiB 堆栈),在这种情况下,尽早加载可能是有意义的。或者只使用 BSS,例如lcomm buf, 1<<16

为了提高效率,最好将缓冲区设为 2 的幂,或者至少是页面大小 (4k) 的倍数。如果你用它来复制文件,第一次之后的每次读取都将在页面末尾附近开始,而不是复制整个 4k 页,因此内核的copy_to_user(读取)和copy_from_user(写入)将每次读取/写入时会触及 17 页内核内存而不是 16 页。文件数据的页面缓存可能不在连续的内核地址中,因此每个单独的 4k 页面需要一些开销才能找到,并为 (rep movsb 启动单独的 memcpy在具有 ERMSB 功能的现代 CPU 上)。同样对于磁盘 I/O,内核必须将您的写入缓冲到硬件扇区大小和/或文件系统块大小的若干倍数的对齐块中。

从管道读取时,64KiB 显然是一个不错的选择,出于同样的原因,这场比赛是可能的。留下 1 个字节显然是低效的。此外,64k 小于 L2 缓存大小,因此当您再次写入时,与用户空间(在系统调用中的内核内部)的复制可以从 L2 缓存重新读取。但是更小的尺寸意味着更多的系统调用,并且每个系统调用都有很大的开销(尤其是在现代内核中使用 Meltdown 和 Spectre 缓解措施。)

64KiB 到 128KiB 是缓冲区大小的最佳选择,因为 256KiB L2 缓存是典型的。 (相关:code golf: Fastest yes in the West 在我的 Skylake 桌面上调整了一个程序,该程序仅使用 x86-64 Linux 进行 write 系统调用,并具有分析/基准测试结果。)

机器代码中的任何内容都不会像 0xFFFF 那样从适合 uint16_t 的大小中受益; int8_t 或 int32_t 与 64 位代码中的立即操作数大小相关。 (或者 uint32_t 如果您像 mov $imm32, %edx 一样进行零扩展以零扩展为 RDX。)

不要关闭stdin;你无条件地运行close。关闭标准输入不会影响父进程的标准输入,所以它在这个程序中应该不是问题,但close 的全部意义似乎是让它更像一个你可以在大型程序中使用的函数。因此,您应该将复制 fd 到标准输出与文件处理分开。

使用#include <asm/unistd.h> 获取电话号码,而不是对其进行硬编码。它们保证稳定,但仅使用命名常量更易于人类阅读/自我记录,并避免任何复制错误的风险。 (Build with gcc -nostdlib -static asmcat.S -o asmcat;GCC 在汇编之前通过 C 预处理器运行 .S 文件,这与 .s 不同)

样式:我喜欢将操作数缩进到一致的列中,这样它们就不会拥挤助记符。同样,cmets 应该舒适地位于操作数的右侧,这样您就可以向下扫描列以查找访问任何给定寄存器的指令,而不会被较短指令的 cmets 分心。

注释内容:指令本身已经说明了它的作用,注释应该描述语义。 (我不需要 cmets 来提醒我调用约定,比如系统调用会在 RAX 中留下结果,但即使你这样做了,用它的 C 版本总结系统调用也可以很好地提醒我哪个 arg 是哪个.喜欢open(argv[1], O_RDONLY)。)

我还喜欢删除多余的操作数大小的后缀;寄存器大小意味着操作数大小(就像 Intel 语法一样)。请注意,将 64 位寄存器归零只需要 xorl;写入 32 位寄存器隐式零扩展为 64 位。您的代码有时对于事物应该是 32 位还是 64 位不一致。在我的重写中,我尽可能地使用了 32 位。 (除了cmp %rax, %rdx write 的返回值,这似乎是制作 64 位的好主意,尽管我认为没有任何真正的原因。)


我的重写:

我删除了 call/ret 的东西,只是让它进入清理/退出,而不是试图将它分成“函数”。

我还将缓冲区大小准确地更改为 64KiB,以 4k 页面对齐方式分配在堆栈上,并重新排列以简化和保存各处的指令。

还添加了关于短# TODO 评论。对于高达 64k 的管道写入似乎不会发生这种情况。 Linux 只是阻止写入,直到缓冲区有空间,但写入套接字可能会出现问题吗?或者可能只有更大的尺寸,或者像 SIGTSTP 或 SIGSTOP 这样的信号中断write()

#include <asm/unistd.h>
BUFSIZE = 1<<16

.section .text
.globl _start
_start:
    pop  %rax      # argc
    pop  %rdi
    pop  %rdi      # argv[1]
     # you'd only ever want to read args this way in _start, which isn't a function

    and  $-4096, %rsp           # round RSP down to a page boundary.
    sub  $BUFSIZE, %rsp         # reserve 64K buffer aligned by 4k

    dec  %eax      # if argc == 1,  then run with input fd = 0   (stdin)
    jz  .Luse_stdin

    # open argv[1]
    mov     $__NR_open, %eax 
    xor     %esi, %esi     # flags: 0 means read-only.
    xor     %edx, %edx     # mode unused without O_CREAT, but zero it out for peace of mind.
    syscall       # fd = open(argv[1], O_RDONLY)

.Luse_stdin:           # don't use stdin as a symbol name; stdio.h / libc also has one of type FILE*
    mov  %eax, %ebx     # save FD
    mov  %rsp, %rsi     # always read and write the same buffer
    jmp  .Lentry        # start with a read then EOF-check as loop condition
              # since we're now error-checking the write,
              # rotating the loop maybe wasn't helpful after all
              # and perhaps just read at the top so we can fall into it would work equally well

read_and_write:              # do {
    # print the file
    mov     %eax, %edx             # size = read_size
    mov     $__NR_write, %eax      # syscall #1 = write.
    mov     $1, %edi               # output fd always stdout
    #mov     %rsp, %rsi             # buf, done once outside loop
    syscall                        # write(1, buf, read_size)

    cmp     %rax, %rdx             # written size should match request
    jne     cleanup                 # TODO: handle short writes by calling again for the unwritten part of the buffer, e.g. add %rax, %rsi
                                    # but also check for write errors.
.Lentry:
     # read the file.
    mov    $__NR_read, %eax     # xor  %eax, %eax
    mov    %ebx, %edi           # input FD
   # mov    %rsp, %rsi           # done once outside loop
    mov    $BUFSIZE, %edx
    syscall                     # size = read(fd, buf, BUFSIZE)

    test   %eax, %eax
    jg     read_and_write    # }while(read_size > 0);   // until EOF or error
# any negative can be assumed to be an error, since we pass a size smaller than INT_MAX

cleanup:
# fd might be stdin which we don't want to close.
# just exit and let kernel take care of it, or check for fd==0
#    movl $__NR_close, %eax
#    movl %ebx, %edi 
#    syscall          # close (fd)  // return value ignored

exit:
    mov  %eax, %edi             # exit status = last syscall return value. read() = 0 means EOF, success.
    mov  $__NR_exit_group, %eax
    syscall                     # exit_group(status);

对于指令计数,perf stat --all-user ./asmcat /tmp/random &gt; /dev/null 表明它在用户空间中运行了大约 47 条指令,而您的则为 57 条。 (IIRC,性能超过 1,所以我从测量结果中减去了它。)这是有更多的错误检查,例如用于短写。

.text 部分中只有 84 个字节的机器代码(而您的原始代码为 174 个字节),并且我没有使用 lea 1(%rsi), %eax(在 RSI 归零后)而不是 @ 987654369@。 (或者使用mov %eax, %edi 来利用_NR_write == STDIN_FILENO。)

我主要避免使用 R8..R15,因为它们需要 REX 前缀才能在机器代码中访问。

错误处理测试:

$ gcc -nostdlib -static asmcat.S -o asmcat            # build
$ cat /tmp/random | strace ./asmcat > /dev/full

execve("./asmcat", ["./asmcat"], 0x7ffde5e369d0 /* 55 vars */) = 0
read(0, "=head1 NAME\n\n=for comment  Gener"..., 65536) = 65536
write(1, "=head1 NAME\n\n=for comment  Gener"..., 65536) = -1 ENOSPC (No space left on device)
exit_group(-28)                         = ?
+++ exited with 228 +++
$ strace ./asmcat <&-      # close stdin
execve("./asmcat", ["./asmcat"], 0x7ffd0f5048c0 /* 55 vars */) = 0
read(0, 0x7ffc1b3ca000, 65536)          = -1 EBADF (Bad file descriptor)
exit_group(-9)                          = ?
+++ exited with 247 +++
$ strace ./asmcat /noexist
execve("./asmcat", ["./asmcat", "/noexist"], 0x7ffd429f1158 /* 55 vars */) = 0
open("/noexist", O_RDONLY)              = -1 ENOENT (No such file or directory)
read(-2, 0x7ffd4f296000, 65536)         = -1 EBADF (Bad file descriptor)
exit_group(-9)                          = ?
+++ exited with 247 +++

嗯,如果你想做错误处理,可能应该在打开后在 fd 上 test/jl。

【讨论】:

  • 这里的细节非常好。我现在正在重写程序。非常感谢您花时间如此清楚地解释这一点。关于在哪里可以阅读更多关于 linux 内存分页的任何建议?
  • @PeterEngelbert:我刚刚更新了我的重写,如果你想比较我的选择和你的重写。回复:内核内存分页:kernel.org/doc/html/latest/admin-guide/mm/index.html 是该非常广泛主题的内核文档的概述/顶级,包括概念概述的 kernel.org/doc/html/latest/admin-guide/mm/concepts.htmloreilly.com/library/view/system-performance-tuning/059600284X/…系统管理员书籍中关于调整 Linux 系统的一章
  • @PeterEngelbert:查看What Every Programmer Should Know About Memory? 了解有关缓存的更多信息可能也是一个好主意。但实际上,您可以从操作系统教科书中获得很多背景信息/概念。 (和/或如果您已经是学生,则参加操作系统的本科 CS 课程。)
  • @PeterEngelbert:忘了说,除了一本操作系统教科书,en.wikipedia.org/wiki/Virtual_memory#Paged_virtual_memory 是关于内存页面基础知识的非常重要的背景知识。大多数现代 CPU 都遵循该模型,Linux 并没有用它做任何非常奇怪的事情。 IDK 你准备从什么基础开始。
  • @PeterEngelbert:就像我在答案中间的一段中所说的那样,gcc ... foo.S 在组装之前通过 C 预处理器运行它;该段还包括您想要的确切 GCC 选项。 as 本身不能对 #include / #define 做任何事情。您可以使用gcc -v 查看它为此运行的实际命令,并组装+链接。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2018-05-22
  • 2023-03-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-10-15
相关资源
最近更新 更多