杂志排版

杂志排版

学习练习

简介

宏

手写汇编充满了重复。 同一小段指令会一次又一次地出现,几乎没有变化,而能减少样板代码的抽象手段少之又少。 不仅如此,用汇编写代码还得管理那些语义并不直观的值。 这里没有具名形参,底层机制的绝大多数细节都得由程序员手动处理。

本 track 使用的汇编器 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 结束。 名字后面的数字表示这个宏接收多少个实参,可以是 0。 传入实参时,用逗号(,)分隔:

%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 没有定义。

宏内部的标签

如果一个宏定义了普通标签,那么它一旦被用到第二次就会出错,因为那个标签也就被定义了两次。 要在可能被多次调用的宏里使用标签,就必须给这样的标签加上 %% 前缀。 这会告诉汇编器,在每次展开时给这个标签起一个不同的、唯一的名字:

; 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

预处理器运行之后,循环消失了,只剩下四条 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表示无框,1表示横线,2表示方框。

目标地址是一个有效内存地址表达式。 它由基址寄存器、一个立即数以及一个变址寄存器组成,变址寄存器可以按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,全部免费。