news 2026/11/3 19:56:54

AArch64 汇编指令速查:寄存器、寻址与 SIMD/SVE

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AArch64 汇编指令速查:寄存器、寻址与 SIMD/SVE

简介:面向嵌入式、系统软件与编译器等底层开发者的 Armv8-A 架构 Aarch64 汇编指令集参考文档,可用于查证指令语义、编写或调试 A64 汇编、理解 SIMD 与 SVE 等扩展特性。文档以官方指令手册形式组织,覆盖基础算术与逻辑运算、比较分支、内存访问与控制流指令,并延伸至 SIMD 向量运算、SVE 可伸缩向量及 SME 相关指令;同时说明 X0–X31 通用寄存器与 V0–V31 向量寄存器的组织方式、多种寻址模式以及异常与中断处理机制,既适合汇编初学者按条目建立指令体系认知,也便于资深工程师在性能优化与问题定位时核对规则。资源包仅含 1 个 PDF 文件,约 9.7MB,单文件涵盖完整指令条目,便于离线检索与打印。目前已有 2993 人学习下载,对系统梳理 A64 指令体系、补齐体系结构细节具有较高参考价值。

1. 到底什么是 AArch64:一份"64 位菜谱"的目录

厨房换了新灶台,锅的尺寸、抽屉编号、火候档位全变了,老菜谱上那句"中火三分钟"就没法照搬。AArch64 就是这样一个新灶台:它规定了一颗 CPU 有多少个寄存器、每个叫什么名字、一条指令占几个字节、内存地址怎么算出来、调用函数时参数塞在哪儿。你在搜 aarch64 汇编指令或者 arm64 汇编指令时翻到的那份几百页字母表,从 ADC 一路排到后面成串的 LD、ST、MOV,本质就是这本菜谱的目录。它值得搞懂的地方不在于背,而在于某天调试器停在一行看不懂的机器码上,你能立刻认出"这是在取参数""这是在恢复栈",还是"这是在跳去错误处理"。这一份认知,就是读机器码的钥匙。

2. 为什么非要重做一套 64 位指令:定长与寄存器的取舍

32 位时代,地址空间顶到 4GB 就没了,通用寄存器只有十几个,编译器常常为了腾出一个寄存器而写出额外的存取指令。新架构要同时解决两个问题:地址和整数要能装进 64 位,寄存器数量得撑得住现代编译器。但设计者没有在老编码上"加宽",而是直接重写了一套指令编码,这就是 A64 指令集存在的根本原因——它不是为了向后兼容,而是为了把旧包袱一次性扔掉。

2.1 每条指令都是 32 位:整齐带来的取舍

A64 里所有指令都固定占 4 个字节,没有 16 位和 32 位混排的情况。好处是解码器不用猜这一条到底多长,取指、译码、流水线的节奏都能做得很规整,硬件成本低、分支预测也简单。

代价同样明显:32 位里要同时装进操作码、目标寄存器、源寄存器、条件位,剩下的空间已经不多。所以 A64 的立即数范围很小,普通指令只能带一个 12 位的立即数,还可以附带移位。想往寄存器里塞一个真正的 64 位常量,得靠拼。

// 把一个 64 位常量装进 x0(A64 没有"大常数直接进寄存器"的形式) movz x0, #0x1234, lsl #16 // 把 0x1234 填到第 16~31 位,其余全部清零 movk x0, #0x5678, lsl #32 // 只改第 32~47 位,其他位原样保留 movk x0, #0x9abc, lsl #0 // 再把最低 16 位补上

这三条指令的逻辑说明:movz是 move wide with zero,写哪一段就把哪一段填进去、剩下全清零;movk是 move wide with keep,只覆盖目标那 16 位,其余保持不动。两个指令配合,任意 64 位常量都能用最多四条拼出来。参数上那个lsl #16就是"这段 16 位数据从第几位开始放",写错一位,后面所有运算全错,而且错得很安静——编译器不会提醒,只有结果不对时你才会回头查。

同样的取舍也体现在跳转上:普通的B指令只能跳到当前位置前后一定范围内,超出范围就得先算地址、再走寄存器跳转;访问全局变量时不能直接给出 64 位绝对地址,于是有了按 4KB 页对齐计算的 PC 相对寻址。指令表里那些看起来重复的助记符,多半就是这种"编码空间不够"逼出来的补丁。

2.2 X0 到 X30:31 个抽屉和一个"零"

A64 提供了 31 个 64 位通用寄存器,编号 X0 到 X30。每个 X 寄存器都有一个 32 位的"半身"叫 W0 到 W30,写 W 寄存器会把对应 X 寄存器的高 32 位清零,这一点和很多老架构不一样,是初学者最容易忽略的细节之一。

除了这 31 个,还有一个特殊角色:零寄存器。读它永远得到 0,写它等于把数据丢掉。它没有占用编号,而是和栈指针共用同一个编码位置——同一条指令里,那个位置要么解释成栈指针,要么解释成零寄存器,看具体指令而定。汇编器用SP和XZR两个名字帮你区分。

寄存器不是随便用的,函数调用有一套约定,业内叫过程调用标准。简单说:前八个整数参数依次放在 X0 到 X7,返回值放 X0,X8 有时用来传递"结果该写到哪"的地址;X9 到 X15 是临时寄存器,被调用方可以随便用;X19 到 X28 是被调用方保存的,你想用就得先存到栈上、返回前恢复;X29 习惯当帧指针,X30 是链接寄存器,存着"函数干完该回到哪"的地址。

寄存器常见角色谁负责保存
X0–X7前 8 个参数、返回值调用方
X8间接结果地址调用方
X9–X15临时计算调用方
X16–X17链接时的跳板调用方
X19–X28长期变量被调用方
X29帧指针被调用方
X30返回地址被调用方
SP栈指针全局约定

这张表的价值在调试时特别明显:栈回溯看到 X29 一串链式指向,你就知道帧指针没被优化掉;看到返回值莫名其妙是 0,先怀疑是不是把结果写进了 W 寄存器的高位而被清掉。

2.3 不兼容不是缺陷,是重新设计

A64 和老的 32 位指令集(A32、T32)用的是完全不同的编码,两者不能混在同一条执行流里。一个进程要么是 64 位,要么是 32 位,运行时不会在同一条指令流里来回切换。想在一台机器上跑 32 位程序,靠的不是"指令集向下兼容",而是操作系统里同时准备了两套运行环境和两套库,这套约定叫应用程序二进制接口,也就是 ABI。

很多人的第一次翻车就发生在这里:手上一份 32 位的预编译库,程序是 64 位的,链接器报一堆看不懂的符号错误,人却以为是编译参数写错了。实际上是指令宽度、寄存器约定、数据类型长度三样全都对不上。理解这一点,比背一百条指令都有用。

3. 指令表的组织逻辑:算、访存、跳,三大类怎么读

几百页的字母表看着吓人,其实按"这条指令动了什么"来分,只有三类:动寄存器里的数、动内存里的数、动执行的位置。抓住这条主线,剩下的都是变体。

3.1 运算指令与它的"影子":ADD 和一堆别名

算术类指令里最常见的是加法族。ADD只做加法,ADDS做加法并更新标志位,ADC带进位加,减法那边是SUB、SUBS、SBC。逻辑类有AND、ORR、EOR、BIC(按位清除,等于"与非")。

关键在于,指令表里有一大批助记符其实并不产生新编码,它们只是某个基础指令的另一种写法,业内叫别名。比如CMP w0, w1本质就是SUBS xzr, w0, w1——做减法,结果扔进零寄存器,唯一留下的产物是标志位。CMN是ADDS的别名,MOV通常是ORR或ADD的别名,LSL、LSR、ASR这类移位在 A64 里没有独立指令,都是位域操作指令的别名。搞清楚这一层,指令表的页数会瞬间缩水一半。

// 比较两个数,把较大的那个留下 cmp w0, w1 // 实为 subs xzr, w0, w1:做减法,结果丢弃,只为更新标志位 csel w2, w0, w1, gt // gt 为真取 w0,否则取 w1,全程不产生分支

逻辑说明:这段代码实现的是"取较大值",但一次跳转都没有。cmp更新了条件标志,csel是条件选择,根据标志位在两条来源里挑一个写进目标。参数上,gt是有符号比较下的"大于",如果两个数本该按无符号理解,就得换成hi,选错会得到完全相反的答案——负数在无符号视角下是极大的正数,这个坑很多人踩过。

这套条件选择家族还有csinc(条件成立取原值、否则取加一)、csinv、csneg,加上它们的别名cset(条件成立置 1)、csetm(条件成立置全 1)、cinc、cneg。它们的共同思路是:用小段无分支代码替代一次跳转,避免流水线因为分支预测失败而清空。

3.2 访存:一条 LDR 为什么有三种长相

指令表里LDR出现了好几次,后面分别标着 immediate、literal、register。这不是三条不同的指令名重复,而是同一条助记符对应三种不同的编码方式,取决于地址怎么算出来。

立即数形式是基址寄存器加一个常数偏移,最常见:ldr x0, [x1, #16]。字面量形式是以当前指令位置为基准做 PC 相对寻址,常用来读常量池:ldr x0, label。寄存器形式是基址加另一个寄存器,还能带一个移位,用来遍历数组:ldr x0, [x1, x2, lsl #3]——这里的左移 3 位就是乘 8,因为每个元素 8 字节。

除了地址怎么算,还有"什么时候更新基址"的区别。偏移形式不动基址;前变址写成[x1, #16]!,先算新地址、写回基址、再访存;后变址写成[x1], #16,先用旧地址访存、再把基址加上去。三种写法编译出来的机器码不一样,前变址和后变址适合循环里连续走位。

// 一个标准函数的开场与收尾:保存和恢复调用者现场 demo: stp x29, x30, [sp, #-16]! // 前变址:sp 先减 16,再把 x29、x30 一起存进去 mov x29, sp // 建立帧指针,方便栈回溯 // ... 函数体 ... ldp x29, x30, [sp], #16 // 后变址:先从 sp 取值,再把 sp 加回 16 ret // 等价于 br x30,跳回调用者

逻辑说明:stp和ldp是一次搬两个寄存器的成对访存指令,专门为压栈、出栈这种成对操作设计,比两条单独的str/ldr省一次地址计算。参数上[sp, #-16]!的负偏移是因为栈在多数系统上向下生长,先减再存;收尾时用后变址,先读后加,两条指令前后必须对称,少写一个感叹号或者把前后变址搞反,函数返回时就会跳到一段莫名其妙的地址上,这类问题在调试器里表现为"调用栈突然断掉"。

3.3 分支与调用:B、BL、BR 各管什么

控制流这一族里,B是无条件跳转,B.cond带条件,CBZ/CBNZ是"比较结果为零/非零就跳",省掉了先比较再跳的两步。这几个都是相对当前指令位置计算目标,跳转范围有限。

BL是带链接的分支,跳过去之前把返回地址写进 X30,函数调用用它。BR是跳到寄存器里存的地址,用于函数指针、跳转表、switch的稀疏分支。BLR是带链接的寄存器跳转。RET默认就是BR X30的别名,所以"返回"在机器层面和"跳到 X30 存的地址"是同一件事。

理解这层关系,你就明白为什么叶子函数(不调用其他函数的函数)可以完全不碰栈:它不需要保存 X30,直接ret就行。反过来,一个函数里如果调用了别人,X30 会被覆盖,必须先把原来的值存起来——这也是为什么编译器生成的函数开场经常是那句stp x29, x30, [sp, #-16]!。

家族代表指令主要用途
算术逻辑ADD/ADDS/SUB/SUBS/AND/ORR/EOR寄存器之间运算、更新标志
位域与移位UBFM/SBFM/BFM 及其别名 LSL/LSR/ASR移位、取位段、符号扩展
访存LDR/STR/LDP/STP/LDUR/STUR读写内存、成对搬移
分支B/B.cond/CBZ/CBNZ/BL/BR/RET跳转、调用、返回
条件选择CSEL/CSINC/CSINV/CSNEG无分支取值
系统MRS/MSR/SVC/ERET/DSB/ISB访问系统寄存器、异常、屏障

4. 一条指令吃一串数据:SIMD 与 SVE 到底解决了什么

图像里给每个像素加亮度、音频里给每个采样乘系数、矩阵里每一行乘同一个向量——这些操作的共同点是"同一件事重复很多遍"。如果一条指令只能处理一个数,那 CPU 大部分时间花在取指令上,而不是算数上。向量指令要解决的就是这个浪费。

4.1 SIMD:把 128 位的箱子切成格子

SIMD 的思路很朴素:既然寄存器有 128 位宽,为什么只装一个 32 位数?把它切成 4 个 32 位格子,一条加法指令同时算 4 组,理论速度就是 4 倍。

A64 里有 32 个 128 位的向量寄存器,编号 V0 到 V31,它们和浮点寄存器共用同一批物理位置。用的时候靠后缀说明怎么切:.4s是 4 个 32 位有符号整数,.8h是 8 个 16 位,.16b是 16 个 8 位,.4f是 4 个单精度浮点。这套固定宽度、固定切法的向量扩展,通常被叫做 NEON。

// 给一段 32 位整型数组的每个元素加同一个常量(NEON 写法) ldr q0, [x0] // 一次读 128 位,相当于 4 个 int32 add v0.4s, v0.4s, v1.4s // v1 里事先放好 4 份同样的常量,四路同时加 str q0, [x0], #16 // 写回,同时把指针后移 16 字节

逻辑说明:q0是 128 位视图,v0.4s是同一个寄存器的"4 个 32 位"视图,两者指同一块物理空间,只是解释方式不同。参数上,源操作数和目标操作数的排列后缀必须一致,写成v0.4s加v1.8h汇编器会直接拒绝;另外数组长度不是 4 的倍数时,剩下的尾巴要用标量代码单独处理,这段收尾代码往往是向量化里最容易写错的地方。

SIMD 的局限也在这儿:宽度写死 128 位。硬件想加宽,就得让所有已编译的代码重来一遍。

4.2 SVE:向量长度不再是写死的 128

可伸缩向量扩展换了思路:代码里不写死向量有多少个元素,只写"对每个有效元素做这个操作",具体宽度由硬件在运行时决定,可以是 128 位,也可以是 2048 位,按 128 的倍数走。同一份机器码,在不同宽度的处理器上都能跑,不需要重新编译。

支撑这个想法的关键角色是谓词寄存器,编号 P0 到 P15。它不是一个标量布尔值,而是一串按位对应的掩码:第几位是 1,就表示这一条 lane 参与运算。指令后缀里/z表示无效 lane 的结果清零,/m表示无效 lane 保持原值。这一套设计顺手解决了向量化里最烦人的"尾巴"问题——不需要为余数单独写一段标量循环。

// SVE 风格:按当前硬件向量长度分批处理数组 // x0 = 源地址,x1 = 目标地址,x2 = 元素总个数,x3 = 当前处理到第几个 loop: whilelt p0.s, x3, x2 // 生成谓词:下标小于总数的 lane 置 1 b.none end // 一条有效 lane 都没有了,收工 ld1w z0.s, p0/z, [x0, x3, lsl #2] // 只加载有效 lane,其余清零 add z0.s, p0/m, z0.s, #1 // 只对有效 lane 加一 st1w z0.s, p0, [x1, x3, lsl #2] // 只写回有效 lane incw x3 // 按当前向量长度推进下标 b loop end:

逻辑说明:whilelt是"while less than"的缩写,它根据两个标量生成谓词,等价于把"这次循环还剩几个元素"翻译成掩码。b.none检查谓词是否全零,全零说明到头了。incw是 SVE 专有的自增指令,它加的不是 1,而是"当前向量能装多少个 32 位元素",这样同一段代码在 128 位和 512 位硬件上都正确。参数上最容易错的是lsl #2:元素 4 字节,下标要乘 4 才能变成字节偏移,写成#4或者漏掉,读到的就是错位数据。

这里要提醒一句:SVE 和固定宽度的 SIMD 是两套不同的指令编码,向量寄存器名字也不同(Z0–Z31 对 V0–V31),不能指望一段 NEON 代码自动变成 SVE 代码。

4.3 什么时候值得动用向量指令

值得用的场景很集中:数据量大、元素之间互不依赖、每个元素做的是同一套运算。图像逐像素调色、音频逐采样混音、加解密的分组运算、字符串里找某个字符、神经网络里的矩阵乘加,都属于这一类。

不必用的场景同样清楚:元素之间有依赖(比如前缀和、递归),数据量只有几十个(光准备寄存器和收尾的开销就吃掉了收益),或者编译器已经自动向量化得不错。现代编译器在-O2以上对简单循环的自动向量化能力相当强,很多时候你手写的 NEON 未必比它快,先看反汇编再决定要不要动手,能省下大量时间。

5. 最容易混淆的几组概念:名字、别名、兼容性

5.1 AArch64、ARM64、A64、Armv8-A:四个名字一件事?

易混点:这四个词经常在同一段话里出现,看起来像四种不同的东西。

为什么混:它们确实高度重叠。AArch64 指的是 64 位执行状态,A64 指的是这个状态下使用的那套指令集编码,ARM64 更多是工具链和操作系统里沿用的叫法(比如编译器目标三元组里就写作 aarch64),Armv8-A 则是包含 64 位和 32 位两种执行状态的一整代架构版本。

怎么区分:把它们想成"楼层—房间—门牌—楼栋"的关系。楼栋是架构版本,楼层是执行状态,房间是指令集,门牌是各工具链自己的称呼。写代码时你关心的是指令集和调用约定;查资料时如果看到 ARM64,直接按 AArch64 理解即可,两者在绝大多数语境下指的是同一件事。

5.2 指令与别名:CMP 到底是不是"另一条指令"

易混点:指令表里 CMP 和 SUBS 各占一行,看起来是两条独立指令。

为什么混:别名在汇编层面是独立可写的助记符,写起来和真指令没区别,但汇编器最终只输出一种机器码。

怎么区分:看它有没有自己的编码。有独立编码的是真指令,只是换了个写法的叫别名。判断方法很直接:把别名换回基础指令,语义完全等价,那就说明它不占额外编码。这个认知直接影响你读反汇编——反汇编器可能把同一条机器码显示成 CMP,也可能显示成 SUBS,不要以为是两种不同的东西。

5.3 LDR、LDAR、LDADD:都读内存,差别在哪

易混点:三者都从内存里取数据,助记符又都以 LD 开头。

为什么混:名字像,作用域却完全不同。LDR是普通加载,编译器可以随意重排、合并、省略;LDAR是带获取语义的加载,保证它之后的内存访问不会跑到它前面去,用在锁、标志位这类同步场景;LDADD属于原子操作家族,读的同时还会往内存里加一个值,整个动作不可分割。

怎么区分:问自己"这次读内存,别人会不会同时改它"。单线程或者确定没人并发写,用普通加载;两个线程靠一个变量握手,用带获取/释放语义的那几个;要做无锁计数器,才轮到原子操作。用错了不会立刻报错,而是在高并发下偶发数据错乱,属于最难查的一类问题。

5.4 能跑 32 位程序,不等于指令集兼容

易混点:看到一台机器能运行老的 32 位软件,就以为 64 位指令集向下兼容 32 位指令。

为什么混:从用户视角看确实"能跑",中间隔了什么完全看不见。

怎么区分:指令集层面是两套编码,互不识别;能跑是因为系统里另外准备了 32 位的执行环境和配套的动态库。判断方法很实际:看这个 32 位程序依赖的库在目标系统上有没有对应版本,没有就是跑不起来,和 CPU 本身支持不支持没关系。部署前先确认依赖链,比事后排查符号缺失要省事得多。

6. 什么时候该翻这份指令集:三个场景和一条边界

真正会去翻 A64 指令表的人,通常是被某个具体问题逼过去的。第一类场景是看反汇编定位问题:程序在某一行崩了,调试器给你一段地址和几条指令,你得认出这是不是在解引用一个空指针、是不是栈被写坏了、是不是跳进了一段数据。第二类是写热点代码:某个循环占了七成时间,编译器生成的代码不够好,你想手写一小段向量指令替换掉它。第三类是读编译器输出:怀疑编译器把某个操作优化掉了,或者想确认某个写法到底生成几条指令。

这三类场景的共同点是"带着问题查"。指令表不是用来通读的,它是字典,你不会从头背字典,但你会查字典。

反过来,有几件事不值得做。日常写业务代码时不需要碰它,编译器和标准库已经把这些细节封好了;性能问题先看算法和数据布局,把 O(n²) 改成 O(n log n) 的收益,通常比手写向量指令高一个数量级;也不建议为了"打好基础"去背指令表,背下来的东西三个月不用就会忘,而查手册的能力一直在。

你遇到的问题该查哪部分
崩溃在某条指令,看不懂在干什么该指令的编码与语义、寄存器角色表
函数返回后跳飞了分支与链接、栈帧约定、X29/X30 的保存规则
多线程下偶发数据错乱原子操作、获取/释放语义、内存屏障
循环太慢,想向量化向量寄存器视图、向量指令、SVE 谓词机制
链接报符号错误指令宽度、ABI、数据类型长度

有一个坑值得单独说:拿到一份汇编代码,先别急着逐条读,先看它的整体骨架——开场是不是stp x29, x30,中间有没有成对的ldp,结尾是不是ret。骨架对了,再往里看细节;骨架不对,说明这段代码可能是内联汇编片段或者被优化过的尾部,逐条硬读只会越读越乱。

我自己的习惯是,每次怀疑某段代码在底层出了问题时,先把反汇编拉出来,只圈三样东西:入口的栈操作、所有的内存访问、所有的跳转。三样圈完,问题基本能定位到某几条指令上。这个笨办法帮我省下过很多个通宵——早年我遇到过一次栈被写坏的问题,当时盯着源代码看了整整两天,最后把反汇编一拉,发现是一个内联汇编片段里前后变址的感叹号写反了,栈指针偏移了 8 字节,一路把返回地址踩掉。从那以后我每次碰内联汇编,都强制走一遍"反汇编对照"这一步,不管代码多短。

这套流程背后需要的,其实就是一份能随手查的完整指令表。资料里那份按字母排序的完整 A64 指令集,配上寄存器组织和寻址模式的说明,正好适合放在手边当字典用——遇到不认识的助记符,翻一页就有答案。希望这份东西能帮你在下一次盯着反汇编发愣的时候,少花一点时间。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 19:54:48

Oracle EBS弹性域配置与优化实战指南

1. 弹性域:Oracle EBS系统的"乐高积木"在Oracle EBS这个庞大的ERP系统中,弹性域(Flexfield)就像一套精心设计的乐高积木。想象一下,当标准表单字段无法满足企业独特的业务需求时,传统做法需要修改…

作者头像 李华
网站建设 2026/11/3 19:54:01

信源到质量校验:可交付的数据采集清洗系统设计

简介:围绕数据清洗与采集流程的论文文档,面向大数据入门学习者、课程论文写作者及需要搭建采集方案的技术人员,重点梳理数据采集在数据分析前端的作用、网络爬虫的分类与适用场景,以及采集系统从信源管理、任务调度、缓存队列到存…

作者头像 李华
网站建设 2026/11/3 19:52:38

项目质量管理全流程:规划、保证与控制实践

1. 项目质量管理概述 在项目管理领域,质量管理是确保项目成果符合预期标准的关键环节。作为项目经理,我经历过多个因质量管理不到位而导致项目返工甚至失败的案例。质量管理不是简单的检查清单,而是贯穿项目全生命周期的系统性工作。 现代项…

作者头像 李华
网站建设 2026/11/3 19:51:33

Redis集群模式详解:原理、搭建与优化实践

1. Redis集群模式概述Redis集群模式是Redis官方提供的分布式解决方案,它通过数据分片(Sharding)的方式实现水平扩展,允许在多个Redis节点间自动分区数据。与主从复制模式不同,集群模式不仅提供数据冗余,更重…

作者头像 李华
网站建设 2026/11/3 19:50:55

解决IDEA创建Maven项目时的乱码与下载失败问题

1. 问题现象与背景分析最近在使用IDEA创建Maven项目时,不少开发者遇到了一个典型错误:执行mvn archetype:generate命令时,控制台突然出现红色乱码,同时伴随插件下载失败的情况。这个报错通常表现为:[ERROR] Failed to …

作者头像 李华
网站建设 2026/11/3 19:50:43

LeetCode 283题解析:双指针法移动零元素

1. 题目背景与需求分析LeetCode 283题"移动零"是一个经典的数组操作问题,题目要求将一个包含零元素的数组中的所有零移动到数组末尾,同时保持非零元素的相对顺序不变。这个问题看似简单,但考察了程序员对数组操作、双指针技巧和边界…

作者头像 李华