循環(huán):LOOP指令與CX寄存器調(diào)試指南)
匯編這門語言有個挺有意思的地方越是基礎的指令越容易在實際調(diào)試時把人卡住。循環(huán)和CX 寄存器就是典型例子??唇坛涛宸昼娋湍鼙诚翷OOP指令的語義但真正上手寫程序第一次跑出來的結果十有八九是錯的——要么死循環(huán)要么循環(huán)次數(shù)差一要么程序跑得好好的突然跳飛了。這篇內(nèi)容圍繞 8086 實模式下的計數(shù)循環(huán)展開把LOOP系列指令、CX 計數(shù)器的隱含用途、位移量的計算方式、嵌套循環(huán)的保護方案、DEBUG 單步驗證的方法以及塊復制和循環(huán)展開的效率取舍一路講透。剛接觸匯編、正在做課程設計或者想搞明白為什么我的循環(huán)多跑了一次的讀者可以直接照著復現(xiàn)已經(jīng)能寫完整程序、但對指令編碼和標志位影響沒細究過的同行也能從中找到一些平時容易忽略的細節(jié)。文中所有代碼都在 8086 指令集范圍內(nèi)用 MASM/TASM 風格書寫配 DOSBox 或?qū)嶓w機都能跑。1. 先弄清楚 CX 在循環(huán)里到底扮演什么角色1.1 從一條 LOOP 指令說起LOOP這條指令短得離譜機器碼只有兩個字節(jié)格式是E2 cb其中cb是一個 8 位有符號位移量??伤鼉?nèi)部干的事并不簡單CPU 執(zhí)行到LOOP時會一口氣做完三步先把 CX 的內(nèi)容減一結果寫回 CX然后判斷 CX 是不是等于零如果不等于零就把 IP 加上那個 8 位位移量跳到目標地址繼續(xù)執(zhí)行如果等于零就什么都不做順序執(zhí)行下一條指令。請注意順序是先減、后判這個順序決定了 CX 初值和實際循環(huán)次數(shù)之間的對應關系。CX 裝 5循環(huán)體就執(zhí)行 5 次最后一次執(zhí)行完之后 CX 減到 0LOOP不再跳轉(zhuǎn)循環(huán)結束。這個關系看起來很直觀但正因為太直觀很多人寫代碼的時候默認我傳進來 0 就是一次都不循環(huán)結果程序在主頻只有幾兆赫茲的老機器上空轉(zhuǎn)六萬多次才停下來——這個問題后面會專門用一節(jié)講。把LOOP拆開它其實等價于這么兩條指令的組合DEC CX JNZ 標號區(qū)別在于LOOP只占 2 字節(jié)而DEC CX加JNZ rel8要占 3 字節(jié)更重要的是LOOP不修改任何標志位而DEC會實打?qū)嵉馗膭?ZF、SF、PF、AF、OF。這個差異在循環(huán)體依賴標志位的場合是致命的后面 2.3 節(jié)會展開。1.2 CX 的四個兼職身份CX 在 8086 里被定名成計數(shù)寄存器這個名字不是隨便起的。除了LOOP系列指令默認拿它當循環(huán)計數(shù)器它至少還有三份兼職寫循環(huán)的時候很容易撞車。第一份兼職是移位次數(shù)。8086 的移位和循環(huán)移位指令SHL、SHR、SAL、SAR、ROL、ROR、RCL、RCR移位次數(shù)只允許兩種形式寫死常數(shù) 1或者用 CL 指定。注意是 CL也就是 CX 的低八位。這是硬件層面的限制8086 沒有提供立即數(shù)移位的編碼方式。所以只要你的循環(huán)體里出現(xiàn)了變量移位CX 的低字節(jié)就被動了如果不小心把計數(shù)器也放在 CX 上循環(huán)次數(shù)立刻失控。第二份兼職是串操作重復次數(shù)。REP、REPE、REPNE這些前綴指令本質(zhì)上就是以 CX 為次數(shù)、每執(zhí)行一次串操作自動減一的硬件循環(huán)。REP MOVSB干的事情和手寫一個LOOP加MOVSB一模一樣只是更快。第三份兼職是除法除數(shù)。DIV CX、IDIV CX會把 CX 當除數(shù)用這在循環(huán)體里做整數(shù)運算時也得留個心眼。一份兼職撞上另一份循環(huán)就亂了。我見過最典型的翻車現(xiàn)場是循環(huán)體里調(diào)了個子程序子程序內(nèi)部為了字符串拷貝把 CX 設成了字符串長度返回之后外層的循環(huán)計數(shù)器已經(jīng)被改成別的數(shù)了外層循環(huán)要么提前結束要么多跑幾十次。提示寫循環(huán)之前先掃一遍循環(huán)體看看里面有沒有MOV CX、REP前綴、SHL AX, CL這類會碰到 CX 的指令。有的話要么換寄存器要么進循環(huán)體之前壓棧保護。1.3 為什么不用 DX 或者 BX 來計數(shù)既然 CX 這么容易被別的指令搶走為什么不用 DX 或者 BX 當計數(shù)器原因有三個都很實際。第一個是指令長度。LOOP是專門為 CX 設計的別的寄存器沒有對應的單指令循環(huán)。你要是用 DX只能寫成DEC DX加JNZ 標號三條字節(jié)起步循環(huán)體稍微長一點的程序字節(jié)數(shù)累積起來就是可觀的差異。在當年內(nèi)存按 KB 計算的年代這個差異值得計較。第二個是可讀性約定。匯編代碼是給人看的不是只給機器看的。整個 8086 生態(tài)里CX 當計數(shù)器、SI 當源指針、DI 當目標指針、AX 當累加器、BX 當基址這是一套約定俗成的分工。你按這個分工寫別人接手一眼就知道每行在干嘛你偏要用 BX 計數(shù)看代碼的人每次都得回頭確認一遍寄存器含義。第三個是和 REP 前綴的配合。如果你的循環(huán)體內(nèi)核其實是串操作直接用REP前綴比手寫LOOP快得多而REP只認 CX。用別的寄存器計數(shù)就沒法享受這個加速。當然這不是死規(guī)矩。循環(huán)嵌套的時候 CX 只有一個內(nèi)層循環(huán)我通常就用 DI 或 BX 配合DEC、JNZ來寫省掉壓棧開銷。這個取舍后面 3.4 節(jié)會講。2. LOOP 家族的指令細節(jié)與位移計算2.1 LOOP / LOOPE / LOOPNE / JCXZ 的差別LOOP不是孤零零一條指令它有一個家族四條指令各有各的適用場景。把它們的判斷條件列清楚比背定義管用。指令機器碼執(zhí)行動作轉(zhuǎn)移條件典型用途LOOPE2 cbCX 減一CX ≠ 0固定次數(shù)的計數(shù)循環(huán)LOOPE同LOOPZE1 cbCX 減一CX ≠ 0 且 ZF 1查找循環(huán)找到即停LOOPNE同LOOPNZE0 cbCX 減一CX ≠ 0 且 ZF 0查找循環(huán)沒找到繼續(xù)JCXZE3 cb不改變 CXCX 0循環(huán)入口前的空次數(shù)判斷LOOPE和LOOPNE這兩個帶條件判斷的版本很適合寫在數(shù)組里找某個值這類邏輯。比如在一串字節(jié)里找第一個等于 0 的元素每輪比較完用CMP設置 ZF然后LOOPNE繼續(xù)找找到的時候 ZF 1循環(huán)因為條件不滿足而退出此時 CX 的值還能反推出元素的位置。這里有個容易被忽略的點LOOPE因為要同時看 ZF它的退出條件是或的關系——要么 CX 走到 0要么 ZF 不滿足任意一個成立就退出。這意味著循環(huán)結束后你必須再判斷一次到底是哪種情況退出的否則分不清找到了還是找完了沒找到。老手通常會在循環(huán)之后跟一條條件跳轉(zhuǎn)把兩種結局分開處理。JCXZ則是另一個角色。它不修改 CX也不影響標志位只是單純地看 CX 是不是零。因為它不遞減 CX所以不能當循環(huán)用只能放在循環(huán)前面防止次數(shù)為 0 卻還進循環(huán)體的意外。2.2 位移量是怎么算出來的LOOP的位移字段只有一個字節(jié)是有符號數(shù)取值范圍 -128 到 127。這個數(shù)字不是標號的絕對地址而是相對下一條指令的偏移。CPU 執(zhí)行LOOP時IP 已經(jīng)指向了LOOP的下一條指令所以用下一條指令的地址 位移量才是跳轉(zhuǎn)目標。拿一段實際的代碼來算。假設代碼段起始偏移是 0000H0000 B9 06 00 MOV CX, 6 0003 BE 00 00 MOV SI, 0 0006 B8 00 00 MOV AX, 0 0009 03 04 ADD AX, [SI] 000B 46 INC SI 000C 46 INC SI 000D E2 FA LOOP 0009LOOP指令本身位于 000DH占兩個字節(jié)所以下一條指令的地址是 000FH。目標是 0009H。位移量 0009H - 000FH -6用 8 位補碼表示就是 FAH。所以機器碼是E2 FA跟上面寫的對上了。反過來如果你在 DEBUG 里看到一條E2 F9怎么推目標地址F9H 作為有符號數(shù)是 -7假設這條指令在 000EH下一條就是 0010H0010H - 7 0009H。目標標號在 0009H。這套計算沒什么技巧但必須會因為調(diào)試的時候沒有源代碼只有機器碼你得能從字節(jié)反推出跳轉(zhuǎn)目標才能判斷跳飛了是跳到哪兒去了。至于 ±128 字節(jié)的限制這是 8 位位移字段的硬邊界跟標號寫在哪沒關系。循環(huán)體寫長了LOOP就夠不著。這個坑后面 4.3 節(jié)有專門的改寫方案。注意不同匯編器對LOOP短跳轉(zhuǎn)越界的報錯措辭不一樣MASM 一般提示Jump out of rangeNASM 會說short jump out of range。看到這類報錯先量一下循環(huán)體長度基本就定位了。2.3 LOOP 不碰標志位這件事有多重要前面提過LOOP和DEC CX加JNZ的等價性但有一處不等價標志位。LOOP做的事情只有兩件CX 減一、判斷是否為零全程不動標志寄存器。DEC CX則會更新 ZF、SF、PF、AF、OF。為什么不改標志這么重要舉一個具體場景。循環(huán)體內(nèi)部做比較比較結果保存在 ZF 里然后循環(huán)結束——如果你用DEC CX / JNZ收尾DEC會先把 ZF 覆蓋掉你原來比較出來的結果就沒了。而用LOOP收尾ZF 原封不動出了循環(huán)還能接著JE、JNE這就是差別。再比如循環(huán)體里有CMP [SI], AL配合LOOPE做查找如果LOOPE換成DEC CX加JNZ那DEC在LOOPE的位置上會先動標志LOOPE賴以判斷的 ZF 就不可靠了。這類 bug 特別隱蔽程序不一定崩但結果不對且很可能在不同機器上表現(xiàn)不一樣。所以我的經(jīng)驗是只要循環(huán)靠 CX 計數(shù)且循環(huán)體內(nèi)有依賴標志位的比較運算一律優(yōu)先用LOOP收尾。只有在循環(huán)體超長導致LOOP夠不著或者需要換寄存器計數(shù)的時候才退而求其次用DEC加JNZ并且在循環(huán)出口處重新做一次判斷不要指望標志位能活下來。3. 完整實操從求和程序到 DEBUG 單步驗證3.1 環(huán)境與源程序骨架這一節(jié)用一個數(shù)組求和的完整程序把前面講的點串起來。程序在 DOS 實模式下跑需要一個 8086 匯編器和 DOSBox 之類的兼容環(huán)境。我平時用的是 MASM 5.0 配合 DOSBox源文件后綴.asm編譯鏈接之后生成.exe用 DEBUG 加載調(diào)試。程序需求很樸素內(nèi)存里放六個 16 位整數(shù)用循環(huán)把它們加起來結果存到內(nèi)存變量里。選這個例子是因為它結構干凈循環(huán)體只有兩條指令位移量小適合先把手感建立起來再上復雜的。DATA SEGMENT ARRAY DW 12, 34, 56, 78, 90, 11 COUNT EQU 6 TOTAL DW 0 DATA ENDS STACK1 SEGMENT STACK DW 64 DUP(0) STACK1 ENDS CODE SEGMENT ASSUME CS:CODE, DS:DATA, SS:STACK1 START: MOV AX, DATA MOV DS, AX MOV CX, COUNT ; 計數(shù)器初值 元素個數(shù) LEA SI, ARRAY ; SI 指向數(shù)組首地址 XOR AX, AX ; 累加器清零同時清掉 CF NEXT: ADD AX, [SI] ; 累加當前元素 ADD SI, 2 ; 指向下一個字 LOOP NEXT ; CX 減一非零繼續(xù) MOV TOTAL, AX ; 結果落地 MOV AH, 4CH INT 21H ; 返回 DOS CODE ENDS END START程序骨架里有幾個細節(jié)值得掰開說。MOV AX, DATA / MOV DS, AX這兩行不能省也不能寫成MOV DS, DATA——8086 不允許直接把段地址立即數(shù)送進段寄存器必須借道通用寄存器。這是實模式匯編的入門級坑第一節(jié)課基本都會踩一次。XOR AX, AX比MOV AX, 0短一個字節(jié)而且是公認的清零慣用法。這里還順帶把 CF 清了為后面可能的進位運算留下干凈狀態(tài)。ADD SI, 2這一行在純 8086 上會被匯編成81 C6 02 00四個字節(jié)。因為 8086 的加法立即數(shù)指令只有 16 位立即數(shù)的編碼形式80286 之后才引入了 8 位立即數(shù)的緊湊格式。如果你想讓代碼更短可以寫成兩條INC SI各占一字節(jié)省兩個字節(jié)但執(zhí)行時間反而更長——兩條指令的取指和譯碼開銷抵不過一次四字節(jié)指令。這個取舍在現(xiàn)代 CPU 上意義不大但在老機器上曾經(jīng)是值得爭論的話題。3.2 逐條拆解求和循環(huán)從NEXT標號開始的循環(huán)體只有三條指令把執(zhí)行過程走一遍。初始狀態(tài)CX 6SI 指向 ARRAY 首地址AX 0。第一輪ADD AX, [SI]把 12 加進 AXAX 變成 12ADD SI, 2讓 SI 前進兩個字LOOP把 CX 減到 5非零跳回NEXT。第二輪加 34AX 變成 46CX 減到 4繼續(xù)。依此類推第六輪加 11AX 得到最終的和 281然后 CX 減到 0LOOP不跳轉(zhuǎn)落到MOV TOTAL, AX。六個數(shù)字手算一遍12 34 46 56 102 78 180 90 270 11 281。程序跑出來的TOTAL應該是 0121H因為 281 的十進制轉(zhuǎn)十六進制是 0119H——等一下281 256 25 100H 19H 119H。對0119H。這個數(shù)字在 DEBUG 里可以用D DS:0直接看數(shù)據(jù)段驗證。數(shù)組占 12 個字節(jié)TOTAL緊跟在后面偏移是 000CH值是19 01小端序低字節(jié)在前??吹竭@個序列程序就對了。有個地方容易出問題ADD SI, 2的步長必須和數(shù)據(jù)類型匹配。數(shù)組是DW定義的每個元素兩個字節(jié)所以步長是 2。如果定義成DB步長就得改成 1如果寫錯了讀到的是相鄰兩個字節(jié)拼起來的數(shù)結果會莫名其妙。這類錯誤在調(diào)試時表現(xiàn)為結果看著像個正常數(shù)但就是不對而且往往前幾個數(shù)還是對的后面才開始飄。提示累加類程序的經(jīng)典自檢方法是拿第一個元素單獨跑一次——把 COUNT 改成 1看結果是不是等于第一個元素。如果是說明尋址和累加邏輯沒問題再把 COUNT 改回去。這個手法能把邏輯錯和步長錯快速區(qū)分開。3.3 用 DEBUG 盯住 CX 的每一次變化代碼寫完編譯通過不等于程序是對的。匯編這門東西靜態(tài)看代碼只能發(fā)現(xiàn)語法錯誤邏輯錯誤全靠調(diào)試器抓。DEBUG 的三個命令足夠應付絕大多數(shù)循環(huán)問題R看寄存器T單步G帶斷點運行。先用T命令走幾步看看 CX 的變化節(jié)奏-r AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI0000 DI0000 DS0B2A ES0B2A SS0B37 CS0B2A IP0010 NV UP EI PL ZR NA PE NC 0B2A:0010 B90600 MOV CX,0006 -t AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI0000 DI0000 0B2A:0013 BE0C00 MOV SI,000C -t -t AX0000 BX0000 CX0006 DX0000 SP0100 BP0000 SI000C DI0000 0B2A:0019 0304 ADD AX,[SI] -t AX000C BX0000 CX0006 DX0000 SP0100 BP0000 SI000C DI0000上面是示意輸出具體段值隨環(huán)境變化但觀察點很明確MOV CX, 6執(zhí)行后 CX 是 0006ADD AX,[SI]執(zhí)行后 AX 變成 000CH也就是十進制的 12正好是第一個元素此時 CX 還是 0006因為還沒執(zhí)行到LOOP。繼續(xù)T到LOOP那條指令再走一步CX 就會變成 0005IP 跳回NEXT。真正高效的調(diào)試方式是G加斷點。在LOOP指令的地址上下斷點反復G每停一次看一次 CX能在一分鐘內(nèi)確認循環(huán)次數(shù)對不對-g 001f AX002E BX0000 CX0006 DX0000 ... -g 001f AX005C BX0000 CX0005 DX0000 ...注意LOOP停在斷點上時CX 還沒被遞減所以第一次停在斷點看到的是 6第二次是 5。這個停在斷點上看到的是遞減前的值的細節(jié)第一次用斷點調(diào)試循環(huán)的人幾乎都會困惑一下。我在教別人調(diào)試循環(huán)的時候一般會讓他們做一件事把 CX 的每次變化抄在紙上六次一輪和手算的累加過程對一遍。聽起來笨但比對兩三遍之后對先減后判的理解就從概念變成肌肉記憶了。3.4 嵌套循環(huán)里 CX 的保護單層循環(huán)用 CX 天經(jīng)地義一進嵌套就麻煩——CX 只有一個內(nèi)外兩層都要計數(shù)怎么辦三種方案各有適用場合。方案一壓棧保護。外層進內(nèi)層之前PUSH CX內(nèi)層跑完POP CX再執(zhí)行外層的LOOP。這是最直白、最不容易出錯的做法。MOV CX, 5 ; 外層次數(shù) OUTER: PUSH CX ; 保護外層計數(shù) MOV CX, 4 ; 內(nèi)層次數(shù) INNER: ; 內(nèi)層循環(huán)體 LOOP INNER POP CX ; 恢復外層計數(shù) LOOP OUTER壓棧的代價是兩次內(nèi)存訪問配上棧操作本身的指令開銷在緊湊的內(nèi)層循環(huán)里不算便宜。但它的好處是絕對可靠不用擔心內(nèi)層代碼有沒有動過 CX。方案二換寄存器。外層用 CX 走LOOP內(nèi)層用 DI 或 BX 配合DEC / JNZ兩個計數(shù)器互不干擾。MOV CX, 5 ; 外層 OUTER: MOV DI, 4 ; 內(nèi)層 INNER: ; 內(nèi)層循環(huán)體 DEC DI JNZ INNER LOOP OUTER省掉了壓棧彈棧但多了DEC的指令開銷而且DEC DI會修改標志位。內(nèi)層循環(huán)依賴標志位的場合要小心。方案三計數(shù)器放內(nèi)存。用一個DW變量當內(nèi)層計數(shù)器DEC加JNZ直接操作內(nèi)存。速度最慢但寄存器壓力最小適合內(nèi)層循環(huán)體已經(jīng)把寄存器用得七七八八的情況。三種方案怎么選我的判斷標準是看內(nèi)層循環(huán)體的長度和調(diào)用頻率。內(nèi)層循環(huán)體短、跑得次數(shù)多選方案二內(nèi)層循環(huán)體長、邏輯復雜選方案一求穩(wěn)寄存器實在不夠用了才考慮方案三。嵌套層次超過兩層的時候建議直接把計數(shù)器都放內(nèi)存代碼可讀性比那點性能重要得多。三層嵌套的壓棧彈棧順序一旦寫錯出錯現(xiàn)場會非常難查——程序不崩只是結果不對而且只在特定輸入下才不對。4. 常見問題與排查技巧實錄4.1 初值給 0跑出 65536 次這是LOOP最經(jīng)典的坑沒有之一。CX 初值為 0 的時候LOOP并不是一次都不循環(huán)而是循環(huán) 65536 次。原因在先減后判這四個字。CX 等于 0執(zhí)行LOOP先減一0 - 1 在 16 位無符號數(shù)里等于 FFFFH也就是 65535不等于零于是跳轉(zhuǎn)。接下來每次減一要走 65535 次才能回到 0。加上第一次總共 65536 次。這個坑在兩種場景下最容易觸發(fā)。第一種循環(huán)次數(shù)來自外部輸入。比如從鍵盤讀一個數(shù)字當循環(huán)次數(shù)用戶輸入 0程序就陷進去了。這種情況下必須用JCXZ在循環(huán)入口前攔一道MOV CX, [COUNT] JCXZ DONE ; 次數(shù)為 0直接跳過 NEXT: ; 循環(huán)體 LOOP NEXT DONE:第二種循環(huán)次數(shù)是算出來的差值。比如從地址 A 到地址 B 復制數(shù)據(jù)長度等于 B 減 A。如果 B 和 A 相等長度是 0LOOP就開始空轉(zhuǎn)。這類運算的結果最好在進循環(huán)之前用CMP CX, 0加JE判一下。注意JCXZ只是在入口判一次如果 CX 是在循環(huán)體內(nèi)部被改到 0它救不了你。所以這道防線只對初值為零有效。還有一種情況更隱蔽CX 的高位被別的東西污染了。比如你本來只想用 CL 存?zhèn)€值結果上面某條指令動了 CHCX 整體變成 0x0500 之類LOOP就會跑 1280 次。排查這類問題的方法是單步到LOOP之前用R命令看 CX 的完整 16 位值而不是只看低字節(jié)。4.2 循環(huán)體里偷吃 CX 的指令CX 被循環(huán)體內(nèi)部的指令改掉是第二大高頻問題。表現(xiàn)形式有兩種一是循環(huán)次數(shù)變多程序耗時明顯拉長二是循環(huán)次數(shù)變少結果只算了一部分。前面提過的三份兼職就是三大嫌疑人。REP前綴最狠。REP MOVSB、REP STOSB執(zhí)行完之后CX 一定變成 0。如果你的外層循環(huán)計數(shù)器也在 CX 上內(nèi)層一個REP就把外層歸零了外層循環(huán)立刻結束程序看起來跑完了但結果只處理了第一趟。變量移位次之。MOV CL, 3加SHL AX, CL這種寫法會把 CX 的低字節(jié)改掉。如果計數(shù)器只用 CL 的一部分且移位次數(shù)很小可能表現(xiàn)成循環(huán)多跑了幾次不容易第一時間發(fā)現(xiàn)。除法最隱蔽。DIV CX執(zhí)行之后CX 本身不變但整個運算把 AX 和 DX 都覆蓋了。如果你的累加器在 AX 上除完就沒了。雖然不直接改 CX但和 CX 相關的運算鏈一起被破壞癥狀同樣表現(xiàn)為數(shù)算錯了。排查這類問題我一般用二分法先把循環(huán)體里的指令注釋掉一半看 CX 是否穩(wěn)定穩(wěn)定就說明問題在被注釋的那一半然后繼續(xù)二分。比逐條讀代碼快得多。4.3 跳超出范圍LOOP 匯編不過循環(huán)體寫長了LOOP的 8 位位移字段裝不下匯編器直接報錯。這個錯誤很好認換一種寫法就能過去NEXT: ; 很長的循環(huán)體 DEC CX JNZ NEXTJNZ用的是 8 位位移跟LOOP一樣有 ±128 的限制換成它不一定解決問題。要徹底擺脫距離限制得用JMP類的近跳轉(zhuǎn)配合條件判斷NEXT: ; 很長的循環(huán)體 DEC CX JZ EXIT JMP NEXT EXIT:JMP是近跳轉(zhuǎn)16 位位移能覆蓋整個段。缺點是每次循環(huán)多一條指令代碼可讀性也差一點。改寫的時候要留意標志位。原來是LOOP不碰標志換成DEC CX之后標志被改了。如果循環(huán)體里最后一次比較的結果需要在循環(huán)外繼續(xù)用得在DEC之前先把結果保存進變量或寄存器否則出了循環(huán)再判斷就是錯的。還有一種情況是循環(huán)體里嵌套了另一個LOOP內(nèi)外層加起來超了 128 字節(jié)但每一層單獨看都沒超。這時候匯編器可能只報外層有問題實際是內(nèi)外層共同的位移和超限。解決思路一樣把內(nèi)層改成DEC / JNZ先給外層騰出空間。4.4 問題速查表把上面這些踩過的坑整理成一張表出問題的時候按癥狀對號入座比從頭讀代碼快。現(xiàn)象可能原因排查動作處理辦法程序卡住不動CX 初值為 0跑 65536 次在循環(huán)入口前看 CX入口加JCXZ或先判零循環(huán)次數(shù)比預期多CX 低字節(jié)被CL移位改掉單步到LOOP前看完整 CX換寄存器存移位次數(shù)循環(huán)提前結束循環(huán)體內(nèi)REP前綴消耗了 CX檢查循環(huán)體里的串操作PUSH CX/POP CX保護累加結果只算了一部分循環(huán)體內(nèi)子程序改了 CX檢查CALL前后 CX 值調(diào)用前壓棧返回后彈棧匯編報Jump out of range循環(huán)體超過 128 字節(jié)數(shù)一下標號到LOOP的距離改DEC CXJNZ或JMP循環(huán)后條件判斷失效用了DEC收尾ZF 被覆蓋看循環(huán)末尾的指令改回LOOP或保存標志嵌套循環(huán)內(nèi)層次數(shù)亂內(nèi)外層共用 CX 未保護看內(nèi)層入口有沒有PUSH CX壓?;驌Q寄存器數(shù)組求和結果偏差指針步長與元素寬度不匹配檢查ADD SI, n的 nDW用 2DB用 1這張表不是窮舉但覆蓋了我這些年實際遇到的八成場景。真正遇到表上沒寫的問題通用的排查順序是先看 CX 初值再看循環(huán)體里有沒有指令碰 CX最后看標志位有沒有被意外覆蓋。這三步走完問題基本就浮出來了。5. 進階塊復制、循環(huán)展開與 REP 前綴5.1 手寫塊復制循環(huán)數(shù)組求和是熱身塊復制才是循環(huán)真正的日常用途。需求簡單把源地址的一片數(shù)據(jù)搬到目標地址。MOV CX, LENGTH ; 要搬的字節(jié)數(shù) LEA SI, SRC LEA DI, DST COPY: MOV AL, [SI] MOV [DI], AL INC SI INC DI LOOP COPY四條指令的循環(huán)體跑一次搬一個字節(jié)。這段代碼約十一個字節(jié)LOOP的位移量是 -9 左右在范圍內(nèi)能正常匯編。這里 SI 和 DI 的分工是約定俗成的SI 是源索引DI 是目標索引很多串操作指令默認就用這兩個寄存器?,F(xiàn)在手寫循環(huán)用它們后面換成REP MOVSB的時候也就是把整個循環(huán)體換成一條指令不用改寄存器約定。塊復制有個經(jīng)典陷阱源和目的區(qū)域重疊。如果目標區(qū)域在源區(qū)域的低地址方向從前往后復制會把還沒讀的源數(shù)據(jù)覆蓋掉。這時候必須改成從高地址往低地址復制也就是指針先指向末尾每次減一。這個方向問題在內(nèi)存管理類的程序里出現(xiàn)頻率不低而且癥狀很怪——復制出來的數(shù)據(jù)有一部分是對的另一部分是錯的看著像隨機損壞。5.2 REP 前綴把 CX 交給硬件REP MOVSB這條指令把上面整個循環(huán)體壓縮成了一行而且速度更快原理就在 CX 身上。REP的本質(zhì)是一個硬件級的循環(huán)控制器。它做的事檢查 CX 是否為零為零就退出不為零就執(zhí)行一次后面的串操作指令CX 減一然后重新檢查。整個過程不需要 CPU 反復取指譯碼效率比軟件循環(huán)高出一截。CLD ; 方向標志清零地址遞增 MOV CX, LENGTH LEA SI, SRC LEA DI, DST REP MOVSB四條有效指令替代了七條機器碼也短了。不過用REP有幾個前提條件必須滿足。第一方向標志要明確。CLD是地址遞增STD是地址遞減。如果之前某個操作把 DF 設成了 1而你又沒清掉REP MOVSB就會從高地址往低地址搬結果和預期相反。我一般習慣在每條REP之前都寫一句CLD雖然多數(shù)時候是多余的但省得排查。第二SI 和 DI 的初值要落在正確的段里。MOVSB默認用 DS:SI 做源、ES:DI 做目的ES 段寄存器必須提前設好否則會寫到一段莫名其妙的內(nèi)存里去。這一點跟手寫循環(huán)不一樣——手寫循環(huán)用MOV [DI], AL的時候DI 默認也是走 DS除非顯式加段前綴。這個默認段的前后差異是初學REP時最常撞的墻。第三CX 一定是次數(shù)。REP執(zhí)行完 CX 歸零做完之后想用原來的長度得提前備份。注意REP MOVSW一次搬一個字比MOVSB快一倍前提是長度是偶數(shù)、源和目標地址都是字對齊的。長度是奇數(shù)的時候得單獨處理最后一個字節(jié)別圖省事。5.3 循環(huán)展開與效率取舍循環(huán)展開是老派優(yōu)化手段思路是把多次迭代的循環(huán)體并排寫出來減少循環(huán)控制指令的開銷。原來要執(zhí)行 N 次LOOP展開四次之后只執(zhí)行 N/4 次剩下的開銷就是四份循環(huán)體。MOV CX, LENGTH SHR CX, 1 ; 每次搬兩個字次數(shù)減半 LEA SI, SRC LEA DI, DST COPY: MOV AX, [SI] MOV [DI], AX ADD SI, 2 ADD DI, 2 LOOP COPY這個例子里循環(huán)體翻了一倍但控制指令的執(zhí)行次數(shù)減半。在老機器上循環(huán)控制指令的取指譯碼開銷占比不小這種改寫能有實實在在的速度提升。但現(xiàn)在要不要這么做得看具體場景?,F(xiàn)代處理器有分支預測和指令緩存LOOP這類短小的循環(huán)控制指令很可能被預測器提前處理掉展開帶來的收益未必明顯反過來代碼體積變大會擠占指令緩存在某些場景下反而更慢。所以我的原則是除非實測發(fā)現(xiàn)這個循環(huán)是熱點否則不主動展開??勺x性的損失是實打?qū)嵉氖找鎱s要打了問號。真要追求塊復制的極致性能現(xiàn)代的做法是用單指令多數(shù)據(jù)擴展一次搬十六個甚至六十四個字節(jié)那已經(jīng)是另一套指令體系了跟 8086 的LOOP不在一個層面上。就 8086 這個平臺本身而言這幾個方案的性能排序大致是REP MOVSW快于手寫MOVSW循環(huán)手寫MOVSW循環(huán)快于MOVSB循環(huán)LOOP收尾略快于DEC / JNZ收尾。差距在具體數(shù)字上不大但在緊密的內(nèi)層循環(huán)里累積起來能看出來。我自己在寫這類程序的時候習慣是先用最直白的LOOP版本把邏輯跑通確認結果正確再考慮要不要換REP或者展開。順序不能反——先優(yōu)化后正確等于給自己挖坑出了錯都不知道是邏輯問題還是優(yōu)化引入的問題。實際動手寫下來我對LOOP和 CX 這套機制的體會是它們的設計非常經(jīng)濟一條兩字節(jié)的指令承擔了循環(huán)控制、計數(shù)遞減、條件判斷三件事代價是把計數(shù)器和一大堆指令綁在了一起。這個設計在資源緊張的年代是聰明的取舍放到今天就變成了要小心繞開的耦合。所以我現(xiàn)在寫匯編只要循環(huán)體超過五行就先把 CX 的使用情況在紙上畫一遍標出哪些指令會碰它、哪些子程序可能動它畫完再動手。這個習慣幫我省掉的調(diào)試時間比任何優(yōu)化技巧都多。真要說有什么值得反復強調(diào)的那就是初值為零這一條——JCXZ多寫一行能省掉一下午。