雜誌排版

雜誌排版

學習練習

簡介

巨集

手寫的組合語言充滿了重複。 同一小段指令一再出現,變化不大,而且能減少樣板程式碼的抽象機制寥寥無幾。 不僅如此,用組合語言寫程式時,還得管理一些沒有明顯語意的值。 這裡沒有具名參數,底層機器的多數細節都必須由程式設計師手動處理。

本課程使用的組譯器 NASM 提供了一些工具,能在這兩個問題上幫助程式設計師。 這些工具在_組譯時期_執行,此時任何指令都還沒送到處理器。 它們會指引組譯器,在原始碼還沒被轉譯成機器碼之前,就先把原始碼改寫好。

這些工具裡最重要的是巨集,可以把它想成寫給組譯器的食譜。 巨集會在使用它的地方展開,就好像那段程式碼本來就是手寫在那裡一樣。

巨集可以接受若干字面的文字片段作為參數,讓它們能在不同情境中重複使用。 不過,巨集不是函式:沒有call,沒有ret,也不會把控制權轉移到程式碼中的另一個位置。

單行巨集

最簡單的定義方式,是用%define為一個值或一小段運算式取名字:

%define WIDTH 8
%define BUFFER_SIZE (WIDTH * 16)

之後每次出現WIDTH,都會在組譯之前被替換成8。 這種替換是文字上的,所以只要名稱出現就會被代換,無論它是在運算元裡,還是在另一個定義之中。

Note

對於數值_常數_,請優先用equ而不是%define。

用%define宣告的單行巨集只會在使用時展開,而且可以重新指定或取消定義。 相對地,equ常數在定義當下就會求值,之後永遠不會被修改。

%define也可以接受_具名_參數,行為就像一個小的行內函式:

%define double(x) ((x) + (x))

    mov eax, double(WIDTH)   ; assembled as mov eax, ((8) + (8))
Caution

請把整個巨集主體和每個參數都用括號包起來。 如果寫成%define square(x) x*x,呼叫square(1+1)會展開成1+1*1+1,也就是3。 若寫成%define square(x) ((x)*(x)),同一個呼叫會展開成((1+1)*(1+1)),也就是4。

用%define定義的名稱有大小寫之分。 如果有需要的話,%idefine這個變體可以讓名稱不分大小寫。

單行巨集很適合用來賦予程式碼中各個部分意義。 小心使用的話,這能提升可讀性,並降低打錯字的機會。 例如,函式內有特定用途的暫存器,可以依它的用途來命名:

%define COUNTER rcx
    ...
.loop:
    ...
    dec COUNTER
    jnz .loop
    ...

多行巨集

用%macro可以捕捉一整段程式碼,行數不限,並以%endmacro結束。 名稱後面接的數字代表這個巨集接受幾個引數,也可以是零。 傳入引數時,引數之間以逗號(,)分隔:

%macro example_macro 0
...
%endmacro

多行巨集中的引數沒有名稱。 它們依照傳入的順序,以%1、%2等來表示:

; Multiplies two values, returning the result in the first argument
%macro mult_two 2        ; %1 = accumulator, %2 = value
    imul %1, %2
%endmacro

    mult_two eax, ecx    ; multiplies eax and ecx and returns the result into eax
    mult_two r8, r9      ; multiplies r8 and r9 and returns the result into r8

要注意的是,引數必須是巨集主體中所執行運算的有效語法。 這裡mult_two eax, ecx和mult_two r8, r9都能組譯成功,但mult_two eax, cl不行,因為imul eax, cl並沒有定義。

巨集內的標籤

定義一般標籤的巨集,一旦被使用 2 次就會出問題,因為那個標籤也會被定義 2 次。 若要在可能被呼叫多次的巨集裡使用標籤,就必須在這種標籤前面加上%%。 這會告訴組譯器,每次展開時都給這個標籤一個不同且唯一的名稱:

; clobbers rcx
%macro clear_n 2         ; %1 = base address, %2 = count of dwords to clear
    xor ecx, ecx
%%loop:
    mov dword [%1 + rcx*4], 0
    inc ecx
    cmp ecx, %2          ; %2 is compared with ecx: it is an immediate or a 32-bit operand
    jb %%loop
%endmacro

    clear_n rdi, 4       ; gets its own %%loop
    clear_n rsi, 8       ; a different %%loop, no clash

在組譯時期重複

用times可以重複單行的程式碼區塊。 在記憶體中宣告重複的值時,這特別有用:

section .data
negative_block: times 16 db -1
; this assembles to 16 db -1 in sequence, i.e., an array of 16 bytes, all equal to -1

如果要重複多行程式碼,我們可以改用%rep:

%rep 2          ; number of repetitions: 2
    add rcx, 32
    mov dword [rsi + rcx], 0
%endrep

注意,重複次數就寫在%rep後面,並用%endrep來結束重複的區塊。

在%rep區塊裡使用組譯時期的計數器很常見,就像執行時期的迴圈會用計數器一樣。 組譯時期的整數常數可以用equ宣告,但組譯時期的整數_變數_則要改用%assign。 和%define不同,%assign會立刻求出一個數字,而且可以重新指定:

    xor eax, eax
%assign i 0
%rep 4
    add eax, i           ; add 0, then 1, then 2, then 3
%assign i i + 1
%endrep

前處理器執行完之後,迴圈就消失了,只留下 4 個add指令,其中的立即值0、1、2、3已經直接寫死在裡面。 這就是由組譯器完成的迴圈展開:沒有計數器暫存器,也沒有分支,只有一路直線前進的程式碼。

條件式組譯

前處理器提供了各種條件判斷。 它們會在多條不同的路徑之間做選擇,並在原處展開自己的主體,但只有在條件成立時才會這麼做。

%if ... %elif ... %else ... %endif會根據數值測試挑選一個分支:

%if WIDTH > 4
    mov eax, 2
%elif WIDTH > 2
    mov eax, 1
%else
    mov eax, 0
%endif

拆分成檔案

巨集通常會放在自己的檔案裡,再透過%include拉進每個需要它們的原始檔:

%include "patterns.mac"

指定的檔案會在組譯之前,原封不動地插入該處,就好像它的內容本來就是在那裡打的一樣。 這麼做能讓定義集中在同一個地方,並讓多個原始檔共用它們。

如果巨集檔可能會被當成獨立檔案組譯,它就應該使用.asm副檔名。 注意,巨集沒有連結性。 即使巨集檔被組譯過,它的巨集也只對%include它的另一個檔案可見。

否則,只被%include的檔案可以用任何副檔名。 使用.asm或.mac都是常見的慣例。

說明

你維護著雜誌排版引擎所賴以建構的巨集庫。 引擎本身,也就是為每一頁排版的那些函式,是固定的,並不由你撰寫。 你要撰寫的是建構它所使用的組譯時期機制。

這個練習不需要你撰寫任何函式,只需要巨集。 這些巨集接著會在另一個檔案中已預先定義好的函式裡被納入與呼叫。

你有四項任務。

Caution

每個巨集都會以任務所指定的確切名稱和引數順序被呼叫,所以請完全按照規定撰寫。 否則檔案將無法組譯。

1. 為格式常數命名

雜誌的格式是由少數幾個固定數字組成,其餘的排版只以名稱來參照它們。 你應該在組譯時期為每一個取一個可讀的名稱:

  • COLUMN_COUNT 是 4
  • COLUMN_STRIDE 是 18
  • SPACE 是 0x20,也就是空白位元組
  • RULE 是 0x3D,也就是位元組 =
  • RULE_WIDTH 是 4
  • PLAIN 是 0x2E,也就是位元組 .
  • RULED 是 0x2D,也就是位元組 -
  • BOXED 是 0x23,也就是位元組 #

引擎會以上述順序回報這些值。

out = {4, 18, 32, 61, 4, 46, 45, 35} // the eight named values, in order

2. 撰寫 fill_run 巨集

引擎藉由蓋印一段連續的單一位元組,來清空長條校樣並畫出刊頭線。 把這個步驟寫成多行巨集,讓引擎在需要填滿連續區段時隨時呼叫。

首先,為暫存器rcx定義一個可讀的名稱 COUNTER。 接著,定義 fill_run 巨集,它應該用某个位元組的 N 份複本填滿一個陣列。 它依序接受三個引數:

  1. 目的地位址,透過 64 位元通用暫存器傳入。
  2. 要寫入的位元組數量(N),透過 64 位元通用暫存器或立即值傳入。
  3. 要寫入的位元組,透過立即值傳入。
fill_run rdi, rax, SPACE
fill_run rdx, RULE_WIDTH, RULE

fill_run 巨集可以任意破壞 COUNTER,但不得破壞其他暫存器。

請注意,位元組數量可以透過 64 位元通用暫存器(例如 rax 或 rsi)傳入,也可以立即值(例如 10 或 4)傳入。 你可以假設這個數量永遠不會是負數。

這個巨集會被呼叫三次,分別位於兩個不同的函式中。

length = 8
page   = {32, 32, 32, 32, 32, 32, 32, 32}

width  = 8
page   = {61, 61, 61, 61, 32, 32, 32, 32} // '====' then spaces

3. 撰寫 lay_columns 巨集

格線對這個格式而言是固定的,因此欄位的起始位置可以在組譯時期計算出來。

定義 lay_columns 巨集,它會寫入 COLUMN_COUNT 個偏移,每個都是一個 64 位元整數。 它依序接受兩個引數:

  1. 目的地位址,透過 64 位元通用暫存器傳入
  2. 左邊界,透過 64 位元通用暫存器傳入
lay_columns rsi, rax

要寫入目的地位址的偏移數量是固定的,等於 COLUMN_COUNT。

每個偏移都是一個 64 位元的值,以 offset[i] = margin + i * COLUMN_STRIDE 計算,其中 i 是從 0 開始的偏移索引,而 margin 是第二個引數。

這個巨集可以破壞它的兩個暫存器引數,但不得破壞其他暫存器。 它只會被呼叫一次。

margin = 3
out    = {3, 21, 39, 57}

4. 撰寫 set_header 巨集

這三個固定區段各有格式所固定的標頭樣式,因此位元組在組譯時期就已確定。

定義 set_header,依序接受兩個引數:

  1. 目的地位址。
  2. 以立即值傳入的樣式 id 常值,其中 0 是 plain、1 是 ruled、2 是 boxed。

目的地位址是一個有效記憶體位址的運算式。 它接受一個基底暫存器(可與以 1、2、4 或 8 縮放的索引暫存器相加),以及一個立即值:

set_header rdx+8*rcx+2, 1

請用條件式組譯(%if / %elif / %else)而非執行時期的分支,來選擇標頭位元組(PLAIN、RULED 或 BOXED)。

這個巨集不應該破壞任何暫存器。 它在同一個函式中被呼叫三次。

pitch = 4
out   = {46, 0, 0, 0, 45, 0, 0, 0, 35} // '.', '-', '#'
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
x86-64 Assembly Exercism

準備好開始 雜誌排版 了嗎?

註冊 Exercism,透過 22 個概念130 個練習 和真人引導來學習並精通 x86-64 Assembly,全部免費。