第十八讲 c字符串

程序员小x大约 13 分钟汇编语言

第十八讲 c字符串

C风格字符串是一个字节数组,其中最后一个字节等于0, 我们可以遍历字符串直到找到0字节来计算出字符串的长度。在 C/C++ 中,这看起来像这样:

size_t strlen(char* s) {
    size_t l = 0;
    while(*s != 0) {
        ++l;
        ++s;
    }
    return l;
}

注意0和字符常量\0是完全相同的。

计算字符串的长度是所有字符串操作的基础。字符串的复制、追加、搜索等操作依赖于字符串的长度。因此,我们希望使此操作尽可能快。其他字符串操作如下所示:

  • strcpy – 将一个字符串复制到另一个字符串。
  • strcat – 将两个字符串连接成第三个字符串。
  • strstr – 在字符串中搜索子字符串

上面的strlen的汇编版本如下所示:

strlen:
    ; rdi = char* s
    ; rax = returned length
    xor rax, rax

.while:
    cmp byte [rdi], 0
    je .return
    inc rdi
    inc rax
    jmp .while

.return:
    ret

汇编版本的strlen没有比原始C版本的方法长很多,然而,计算长度时逐字节访问内存有些低效。后面,我们将介绍多种不同的方法来执行此操作。比如我们可以使用带有rep前缀字符串的指令进行加速。也可以使用XMM寄存器并行化的高级方法进行加速。

strcpy的作用时将从一个字符串逐字节复制到另一个字符串。我们假设目标字符串有足够的空间。在C语言中,你可以这样实现:

char* strcpy(char* dest, char* src) {
    while(*src != 0) {
        *dest = *src;
        ++dest;
        ++src;
    }
    *dest = 0; // Copy terminating nul
    return dest;
}

我们返回dest中最后一个终止符0的地址,因为它在下面实现strcat时很有用。

在汇编语言中,我们可以这样实现:

strcpy:
    ; rdi = char* dest
    ; rsi = char* src

.while:
    cmp byte [rsi], 0
    je .done
    movsb     ; movsb 下面有介绍(这里不要去对rdi,rsi递增,因为movsb内部做进行递增)
    jmp .while

.done:
    mov byte [rdi], 0
    mov rax, rdi
    ret

为了连接两个字符串,我们必须将第一个字符串复制到目标中,然后是第二个字符串,最后是终止符0。

char* strcat(char *dest, char* a, char* b) {
    dest = strcpy(dest, a);
    return strcpy(dest, b);
}

strcat的汇编实现如下所示:

strcat:
  ; rdi = char* dest
  ; rsi = char* a
  ; rdx = char* b
  push rbp
  mov rbp, rsp

  call strcpy

  mov rdi, rax
  mov rsi, rdx
  call strcpy

  pop rbp
  ret

strstr是最有趣的。最简单去实现字符串搜索的方法是使用嵌套循环:

char* strstr(char* src, char* ptn) {
  size_t tl = strlen(src);
  size_t pl = strlen(ptn);

  for(size_t = 0; i < tl - pl; ++i) {
    bool matches = true;
    for(size_t j = 0; j < pl; ++j)
      if(src[i + j] != ptn[j]) {
        matches = false;
        break;
      }

    if(matches)
      return src + i;
  }

  return 0; // Not found, null pointer
}

这种实现方式的时间复杂度是O(mn),这里m是源字符串的长度, n是搜索字符串的长度。存在有更快的算法,但它们依赖于模式 ptn 的各种跳过表的预计算(KMP算法)。

为了将其转化为汇编代码,我们需要进行一些修改。首先,我们要消除对strlen的调用。应该可以检测循环中搜索字符串和模式的结尾:

char* strstr(char* src, char* ptn) {
  for(size_t i = 0; ; ++i) {
    bool matches = true;
    for(size_t j = 0; ptn[j] != 0; ++j) {

      if(src[i + j] == 0)
        break;

      if(src[i + j] != ptn[j]) {
        matches = false;
        break;
      }
    }

    if(matches)
      return src + i;
  }

  return 0; // Not found
}

第二个调整是在函数内部,除了 src + i 之外,不使用 src。可以用基于指针的循环替换外部整数循环:

char* strstr(char* src, char* ptn) {
  for(; ; ++src) {
    bool matches = true;
    for(size_t j = 0; ptn[j] != 0; ++j) {

      if(src[j] == 0)
        break;

      if(src[j] != ptn[j]) {
        matches = false;
        break;
      }
    }

    if(matches)
      return src;
  }

  return 0; // Not found
}

我们可以类似地替换内部循环,只要我们保存原始的ptn,这样我们就可以从头开始:

char* strstr(char* src, char* ptn) {
  for(; ; ++src) {
    bool matches = true;
    for(char* p = ptn, char* s = src; *p != 0 && *s != 0; ++p, ++s) 
      if(*s != *p) {
        matches = false;
        break;
      }

    if(matches)
      return src;
  }

  return 0; // Not found
}

因为内循环中的字符串结束条件位于开始处,所以我们可以将其折叠到循环条件中。

最后,我们实际上不需要存储变量 matches 的值。对于内循环中的每次比较,如果两个字节不相等,则跳转到外循环的更新步骤。

另一方面,如果内部循环正常完成,这意味着所有字节都相等,我们可以返回。在这种情况下,程序集跳转到任意标签的能力实际上允许我们简化代码,删除标志变量。

这个函数很简单,我们可以将它转化成汇编代码。

strstr:
  ; rdi = char* src
  ; rsi = char* ptn
  ; rax = returned char*

  ; rsi = s
  ; rdi = p
  ; rax = src
  ; r11 = ptn

  mov rax, rdi
  mov r11, rsi

.src_loop:
  mov rdi, r11       ; p = ptn
  mov rsi, rax       ; s = src

.ptn_loop:
  cmp byte [rdi], 0
  je .end_ptn_loop
  cmp byte [r11], 0
  je .end_ptn_loop

  cmpsb               ; [rdi] != [rsi]
  jne .cont_ptn_loop
  ret                 ; return src

.cont_ptn_loop:
  inc rdi
  inc rsi
  jmp .ptn_loop

.cont_src_loop:
  inc rax
  jmp .src_loop

为了进行byte [rdi]byte [rsi] 的比较,我们使用字符串指令cmpsb,它允许我们比较两个内存操作数,这是我们通常无法做到的。 cmps 是一系列字符串指令之一,所有这些指令都隐式使用 rdi/rsi 作为读取地址。

字符串指令

有许多特定于字符串的指令,全部以s 结尾,它们具有几个共同的特征:

  • 它们都隐式使用 rdirsi 寄存器作为地址。它们要么用作两个源操作数(例如,用于字符串比较 cmps),要么用作类似传输操作的源(rsi)和目标(rdi)。
  • 它们每次都会隐式增加 rdirsi。当重复最终终止时,rdirsi 保留在其最终位置。
  • 它们都接受rep前缀,处理器本身将重复执行指令,我们需要写循环的代码。
  • 它们允许字节、字、双字或四字的字符串。

rep前缀

rep 前缀是修饰符,可应用于少数指令,它会提示CPU在内部重复执行它们,直到满足某些条件。rep系列的前缀有下面这些:

  • rep – 重复 rcx 次,就像loop指令一样,重复执行直到 rcx == 0。所有其他rep前缀也隐式使用此条件;也就是说,repe的停止条件是如果 [rdi] != [rsi]rcx == 0
  • repe – 重复直到 [rdi] != [rsi] 或在某些情况下,直到 [rdi] != rax。这基本上循环直到设置零标志。 repz 是此前缀的别名。
  • repne – 重复直到 [rdi] == [rsi] 或在某些情况下,直到 [rsi] == rax。这基本上循环直到零标志被清除。 repnz 是此前缀的别名。

rep 前缀可以与字符串指令 movs、lods、stos 一起使用。 repe/repne 前缀可与字符串指令 cmpsscas 一起使用。

请注意,无论使用什么前缀,重复都会在 rcx == 0 时终止。

因此,如果您不希望 rcx 对指令重复次数产生任何影响,请将其设置为可能的最大无符号 qword 值:

mov rcx, -1

传输指令:lodsstosmovs

存在从内存到al、从al到内存以及从内存到内存传输数据的三个指令。这些是 lodsstosmovs

指令描述
lodsb[rdi]处的一个字节加载到 al
stosbal 中的字节写入字节 [rdi]
movsb对于旧的模式,movsbDS:(E)SI拷贝一个字节到ES:(E)DI。对于64位模式, movsb[rsi] 拷贝一个字节到 [rdi]movsb操作之后会对rsirdi进行递增或者递减,递增还是递减由EFLAGS寄存器中的方向位(DF: direction flag)来决定, DF=0,则进行递增, DF=1,则进行递减。

rep 前缀使这些操作运行 rcx 次。其他前缀不能与它们一起使用,因此,必须提前知道输入字符串的长度。尽管如此,stosb 仍可用于用常量字节填充内存数组,而 movsb 则可用于将一个字符串复制到另一个字符串中。rep lodsb 并不是特别有用,因为它会重复地将字节加载到 al 中,但随后不会对它们执行任何操作。

比较指令:cmpsscas

cmpsb[rdi] 处的字节与 [rsi] 处的字节进行比较,并相应地更新标志。由于 repe/repne 前缀使用 ZF,因此可用于按字节比较一对字符串,在相等/不相等的第一对字节处停止。

scas 将字节 [rdi]al 进行比较并相应地更新标志。因此,虽然 cmps 对应于按字节比较两个字符串,但 scas 对应于在字符串中搜索存储在 al 中的特定字符。同样,repe/repne 可用于搜索第一次出现等于/不等于 al 的字节。

问题:我们可以使用repe前缀来简化上面strstr的实现吗?

就我个人而言,我不这么认为,因为我们的终止条件比仅仅 !=; 更复杂。我们还必须检查任一列表中的终止 \0,但我很高兴被证明是错误的!

字符串操作 strlen

我们可以使用 scas 来实现 strlen 的支持rep的版本

strlen:
    ; rdi = char*
    ; rax = return length

    mov rcx, -1     ; Max 64-bit unsigned value
    mov r11, rdi    ; Save original rdi
    xor al, al      ; al = '\0'
    repne scasb
    ; Now rdi points to the 0 byte

    sub rdi, rax
    mov rdi, r11    ;restore rdi?(原文错了)
    ret

并行化strlen

我们当前版本的 strlen 一次仅加载一个字节。通过使用 64 位寄存器的全宽度,我们可以在相同的时间内加载 8 个字节,但是我们会遇到在这 8 个字节内的任何位置检测到单个 0 字节的问题。最简单的方法是重复移位并检查寄存器的低/高字节部分:

 mov rbx, qword [rdi]
  cmp bl, 0
  je .low
  cmp bh, 0
  je .high

  ; Check next word
  shr rbx, 16
  add rdi, 2
  cmp bl, 0
  ...

因为有四个字,所以我们需要执行四次移位/比较过程(如果我们手动将它们全部写出来,而不是使用循环,速度会更快)。主要的问题是我们使用通用寄存器(rbx),就像它是一个 8 字节的向量寄存器一样,但事实并非如此。如果我们改用 xmm 寄存器,我们就有更多的指令可供使用,可以平等地对待所有字节。

这是来自 Abner Fog 的快速汇编open in new window C 例程库的优化 strlen 的一部分:

    ; rdi = char* s

    pxor     xmm0, xmm0            ; set to zero
    mov      rax,  rdi
    sub      rax,  10H             ; rax = rdi - 16

    ; Main loop, search 16 bytes at a time
L1: add      rax,  10H             ; increment pointer by 16
    movq     xmm1, [rax]           ; read 16 bytes 
    pcmpeqb  xmm1, xmm0            ; compare 16 bytes with zero
    pmovmskb edx,  xmm1            ; get one bit for each byte result
    bsf      edx,  edx             ; find first 1-bit
    jz       L1                    ; loop if not found  


    ; Zero-byte found. Compute string length        
    sub      rax,  rdi             ; subtract start address
    add      rax,  rdx             ; add byte index
    ret

该算法的基本思想是:

  • 从地址 rax 加载 16 个字节到 xmm1
  • 将每个字节分别与0进行比较(xmm0用0填充)。请记住,向量比较的结果不是更改标志寄存器,而是在比较为真时将每个字节设置为 1,如果不是,则将每个字节设置为 0。
  • xmm1 中每个字节的 1 位(真/假比较结果)复制到 edx 中。
  • 如果未设置位,则重复。
  • 否则,添加设置位的索引,加上 rax 距字符串开头的偏移量,以获得长度。

movq 指令将一个四字从内存加载到 xmm 寄存器中(或将一个四字从 xmm 寄存器写入内存)。 movq 有点慢,因为它允许未对齐的读取; Fog最初的实现使用movdqa,它要求地址是16的倍数;未对齐的字符串通过在进入主循环之前首先检查未对齐的部分进行特殊处理。

pcmpeqb 指令(Compare Packed Equal Bytes 的缩写)按字节比较两个 xmm 寄存器是否相等,如果为 true,则将目标中的每个字节设置为全 1,如果为 false,则将目标中的每个字节设置为全 0。

pmovmskb 指令(移动掩码字节)获取 xmm 寄存器的每个字节组件的高位,并将它们复制到通用寄存器的位中。这实际上为我们提供了相同的 0 或 1 比较结果,但打包到 edx 的位中,而不是分散到 xmm1 上。

bsf 指令(位扫描向前)指令搜索已设置的第一个(最低)位。

  • 如果设置了一个位,则将 edx 设置为该位的索引,并清除零标志。
  • 如果没有设置任何位,则设置零标志

如果 edx 中没有设置位,那么我们加载的 16 个字节中没有一个字节是 0,因此我们还没有到达字符串的末尾。否则,edx 是我们加载的 16 字节内字符串结尾字节的偏移量。

让我们看一个例子来看看它是如何工作的:我们想要找到字符串“The Quick Brown Fox Jumped over the Lazy Dog.”的长度。该字符串有 45 个字符:

ThequickbrownfoxjumpedovertheLazyDog.\0
0123456789101112131415161718192021222324252627282930313233343536373839404142434445

我们预加载 xmm0 全 0:

xmm00000000000000000
Byte0123456789101112131415

循环的第一次迭代会将 Quick Brown 加载到 xmm1 中。

xmm1Thequickbrown
Byte0123456789101112131415

由于这些字节都不是 \0,因此比较会将 xmm1 中的所有内容设置为 0:

xmm00000000000000000
Byte0123456789101112131415

然后我们将其作为单个位复制到 edx 中:edx = 0000000000000000b。

由于没有设置任何位,bsf 将设置零标志 ZF = 1,导致跳转到 L1。

下一个循环,rax = 16,所以我们将加载fox跳转到xmm1。由于这里也不存在零字节,因此该过程会重复。

xmm1thelazydog.\0
Byte0123456789101112131415

比较后,我们将xmm1设置为:

xmm00000000000000100
Byte0123456789101112131415

然后将其复制到 edx 中,格式为 0010000000000000b。 bsf 将把 edx 设置为 13,即设置位的索引,同时还设置 ZF = 0。这将终止循环。

正如预期的那样,字符串的最终长度为 32 (rax) 加 13(位索引)= 45。

附录

https://staffwww.fullcoll.edu/aclifton/cs241/lecture-string-operations.htmlopen in new window

Loading...