Style Guidelines for Assembly Language Programmers
汇编程序员之代码风格指南 作者:Randall Hyde
http://webster.cs.ucr.edu/
译者:jhkdiy
http://jhkdiy.icpcn.com or http://www.20cn.net
e-mail:jhkdiy_gzb@21cn.net
译期:开始于06年7月4号。结束于8月31日
原文总页数:42页。译文总页数:60页。
大家知道这位作者吗?不知道?晕••••。那看过《The Art of Assembly Language》一书吗?该书的作者就是Randall Hyde。
这本书在国外有很高的评价,以至于国内也有了翻译的版本:《汇编语言编程艺术》。由陈曙晖翻译,我买了但还没看^_^!。这份代码风格指南是我在作者主页里找到的,有空就去浏缆一下吧,或许有意外收获哦!
在这漫长的翻译旅途中,自己也是一边翻译一边学习该文档的。翻译完后觉得要写出一个易读的程序其实并不容易,但也不是很难,只要自己坚持遵循该文档的话就可以尽能地做到程序易读了。自己学习汇编语言也有一段时间了,却很少在网上的论坛见到过非常易读的汇编程序,绝大一部分是没有任何注释、带有一大堆a、b、d等变量名的难读程序,有一次看到有人特意将整个C语言源代码改成像文字画一样我就跟他吵了起来。无论是汇编也好、C语言或其它语言也好,很多编程的朋友都不把代码的易读性放在眼里,有的甚至从来不考虑,只要代码能执行起来,程序运行起来就算完事了。更有的程序员为编写难读的代码而自我臭美。而大学里教授编程语言的时候,很少有老师对程序的易读性做过教导或建议,以至于学生们从一开始便没有编写易读代码的意识,这在一定程度上增加了编写难读程序的人员。代码是拿来读的,为了能更快更容易地阅读代码,我们必定要遵循一定的规则。
我真心希望越来越多的朋友能写出可读性好的代码,就算将代码发表到全世界,只要学过该语言的人都能看懂中国人编写的程序,因为它深具可读性,读代码像读诗篇一样流畅而自然。希望这一天早日到来!
jhkdiy
2006-8-31
目录:
1.0简介-------------------------------------------4
1.1 ADDHEX.ASM------------------------------------4
1.2 Graphics Example----------------------8
1.3 S.COM 例子----------------------------11
1.4本文面向的读者-------------------------15
1.5可读性标准-----------------------------16
1.6怎样做到可读性-------------------------17
1.7这份文档的组织-------------------------17
1.8指导、 规则、强制性规则、和例外----------------18
1.9 涉及的语言----------------------------19
2.0程序组织---------------------------------------19
2.1库函数---------------------------------19
2.2公共目标模块---------------------------20
2.3局部模块-------------------------------21
2.4程序的make文件-------------------------22
3.0 模块组织--------------------------------------23
3.1模块属性-------------------------------23
3.1.1模块内聚性---------------------------23
3.1.2模块耦合性---------------------------24
3.1.3模块的物理组织-----------------------25
3.1.4模块接口-------------------------------------25
4.0程序单元组织-----------------------------------27
4.1例程内聚性-----------------------------27
4.1.1例程耦合性---------------------------28
4.1.2例程大小-----------------------------29
4.2主过程和数据的安排-----------------------------30
5.0语句组织---------------------------------------30
6.0注释-------------------------------------------34
6.1什么是一个坏注释?---------------------34
6.2什么是一个好注释?---------------------36
6.3行尾注释VS独立注释---------------------37
6.4未完成的代码---------------------------39
6.5代码中交叉参考到其它文档---------------40
7.0名称、指令、操作数和操作-----------------------41
7.1名称-------------------------------------------41
7.1.1命名约定-------------------------------------43
7.1.2字母大小写考虑-----------------------43
7.1.3缩略语-------------------------------45
7.1.4标志符内各成分的位置-----------------45
7.1.5要避免的名称 ------------------------47
7.2指令、伪指令和伪操作码-----------------48
7.2.1选择最好的指令序列-------------------48
7.2.2控制结构-----------------------------50
7.2.3同意义的指令-------------------------53
8.0数据类型---------------------------------------56
8.1用TYPEDEF定义新的数据类型----------------------56
8.2创建数组类型---------------------------57
8.3在汇编语言里声明结构体-----------------58
8.4数据类型的UCR标准库--------------------59 1.0简介
许多人认为汇编程序难于阅读。虽然大家有这种感觉有许多原因,但最主要的还是汇编语言难以使得程序员写出易读的程序。这并不表示不可能编写出易读的程序,只是它要花费汇编程序员一部分额外的工作来写出易读代码。
为了示范汇编程序的一些公共问题,决定使用下面的程序或程序段。这些都是在Internet上找到的真正用汇编语言编写的程序。每个例子都示范了一个单独的问题。(顺便一提:选择这些程序并不是有意让原作者难吭。这些程序都是在网上找到的汇编代码特例)。 1.1ADDHEX.ASM
%TITLE "两个16进制数相加"
IDEAL
DOSSEG
MODEL small
STACK 256
DATASEG
exitCode db 0
prompt1 db 'Enter value 1: ', 0
prompt2 db 'Enter value 2: ', 0
string db 20 DUP (?)
CODESEG
EXTRN StrLength:proc
EXTRN StrWrite:proc, StrRead:proc, NewLine:proc
EXTRN AscToBin:proc, BinToAscHex:proc
Start:
mov ax,@data
mov ds,ax
mov es,ax
mov di, offset prompt1
call GetValue
push ax
mov di, offset prompt2
call GetValue
pop bx
add ax,bx
mov cx,4
mov di, offset string call BinToAscHex
call StrWrite
Exit:
mov ah,04Ch
mov al,[exitCode]
int 21h
PROC GetValue
call StrWrite
mov di, offset string
mov cl,4
call StrRead
call NewLine
call StrLength
mov bx,cx
mov [word bx + di], 'h'
call AscToBin
ret
ENDP GetValue
END Start
好了,这个程序的最大问题还是相当明显的-除了标题之外完全没有其它注释(译注:国内有太多这样没注释的程序了)。另一个实际的问题则是用来提示用户的字符串出现在程序的一端但用来打印这些字符串的调用代码却出现在另外一个地方(译注:即指代码中的StrWrite等三个函数)。尽管这是个经典的汇编语言编程方法,但却导致了代码难于阅读。另外,相对次要的一个问题是该程序使用了TASM的 “less-than”IDEAL
语法(注:一些一直使用TASM的人会认为这没什么不好。但个别人就不这么认为了,由于他们不熟悉TASM的古怪语言,有时会导致被程序中的几条语句搞混淆)
这个程序也使用了MASM/TASM的“简单化”段定义。微软声称的这个典型特性却给一个“简单”的工程增加了复杂性。如果该程序转换为标准的段定义格式将会更加易读。
(注:使用简单段定义虽然更容易编写高级语言接口的汇编程序,但无论如何,他们都只会使单一的程序问题变得复杂)
在扔掉它之前,该程序还是有两点值得称赞的(遵循了可读性)。第一,该程序员为过程名和本程序使用的变量选择了一组合理的名称(我假定这段代码的作者同时也是该程序调用的库的作者)。程序另一个值得肯定的是助记性好和操作数之间有良好的对齐。
OK,在抱怨这份代码如何难读后,为什么不来个更易读的版本?下面的程序便是,可证明,该版本比上面的版本更加易读。可证明,因为该版本使用了UCR标准库v2.O
并假定读者熟悉该库的细节和特性。
;**************************************************
;
; AddHex-
;
; 这个简单的程序从用户那里获取两个整数值,计算它们的和,
; 并在屏幕上打印结果。
;
; 这个例子使用了“80x86汇编程序员的UCR标准库v2.0”
;
; Randall Hyde
; 12/13/96
title AddHex
.xlist
include ucrlib.a
includelib ucrlib.lib
.list
cseg segment para public 'code'
assume cs:cseg
; GetInt-
;
; 这个函数从键盘读取一个整数并将结果返回到AX寄存器中
;
; 该程序捕获无效的值(太大或无效数字)并需要用户重新输入一个数值。
;
GetInt textequ <call GetInt_p>
GetInt_p proc
push dx ;DX 保存错误代码
GetIntLoop: mov dx, false ;假定没错误
try ;捕获任何可能的错误
FlushGetc ;清空输入来换新行
geti ;读入整数值
except $Conversion ;捕获错误字符
print "Illegal numeric conversion, please re-enter", nl
mov dx, true
except $Overflow ;捕获数值太大
print "Value out of range, please re-enter.",nl
mov dx, true
endtry
cmp dx, true
je GetIntLoop
pop dx
ret
GetInt_p endp
Main proc
InitExcept
print 'Enter value 1: '
GetInt
mov bx, ax
print 'Enter value 2: '
GetInt
print cr, lf, 'The sum of the two values is '
add ax, bx
puti
putcr
Quit: CleanUpEx
ExitPgm ;退出程序的DOS宏
Main endp
cseg ends
sseg segment para stack 'stack'
stk db 256 dup (?)
sseg ends zzzzzzseg segment para public 'zzzzzz'
LastBytes db 16 dup (?)
zzzzzzseg ends
end Main 这里要特别指出的是这份代码比原来的那个AddHex程序要大一点。在细节上,它验证了用户的输入;而原程序并没有这么做。如果那位想更严密地模仿原程序,下面的简单代码即是:
print nl, 'Enter value 1: '
Geti
mov bx, ax
print nl, 'Enter value 2: '
Geti
add ax, bx
putcr
puti
putcr
在这个例子中,两个简单的解决方案改良了程序的可读性:增加注释、将程序格式化得更好点,而且使用了UCR标准库的高级特性来使得编码更为简单并使得打印输出字符串的语句保持它们的字面意思。 1.2Graphics Example
下面的程序段在是网上找到的一个非常大的名为“MODEX.ASM”程序中的一片断。
它处理设置彩色图形显示。
;===================================
;SET_POINT (Xpos%, Ypos%, ColorNum%)
;===================================
;
; 在活动显示页上绘制一个象素。
;
; 输入:Xpos = 待绘制的X坐标
; Ypos = 待绘制的Y坐标
; ColorNum = 待绘制象素的颜色
;
; 退出:没有有意义的值返回
;
SP_STACK STRUC
DW ?,? ; BP, DI
DD ? ; 调用者
SETP_Color DB ?,? ; 绘制点的颜色
SETP_Ypos DW ? ; 绘制点的Y坐标
SETP_Xpos DW ? ; 绘制点的X坐标
SP_STACK ENDS
PUBLIC SET_POINT
SET_POINT PROC FAR
PUSHx BP, DI ; 保护寄存器
MOV BP, SP ; 设置栈框架
LES DI, d CURRENT_PAGE ; 指向活动VGA页
MOV AX, [BP].SETP_Ypos ; 获取Ypos
MUL SCREEN_WIDTH ; 获取行的开始偏移
MOV BX, [BP].SETP_Xpos ; 获取Xpos
MOV CX, BX ; X 偏移 (Bytes) = Xpos/4
SHR BX, 2 ; 偏移 = Width*Ypos + Xpos/4
ADD BX, AX ;
MOV AX, MAP_MASK_PLANE1 ; 图形标志&平面选择寄存器
AND CL, PLANE_BITS ; 获取平面位
SHL AH, CL ; 获取平面选择值
OUT_16 SC_Index, AX ; 选择平面
MOV AL,[BP].SETP_Color ; 获取象素颜色
MOV ES:[DI+BX], AL ; 绘制象素
POPx DI, BP ; 恢复之前保存的寄存器
RET 6 ; 退出并清栈
SET_POINT ENDP 不像之前看的例子,这份代码有很多的注释。确实,注释没有坏处。但是,这份详细的程序却有它自己的一些问题。第一,太多的指令、寄存器名和标识符使用大写字母,大写字母比起小写字母来更难读一些。考虑到将大写字母输入计算机的额外工作,在一个程序中看到这种错误真的感到很羞愧(注3)!这份代码的另一个大问题是作者没有矫正标注域,即注解域,而且操作数真的很very well(虽然这样不可怕,但对于影响程序的可读性来说够坏的了)
下面是经过改良的程序:
;===================================
;
;SetPoint (Xpos%, Ypos%, ColorNum%)
;
;
; 在活动显示页上绘制一个象素。
;
; 输入: Xpos = 待绘制的X坐标
; Ypos = 待绘制的Y坐标
; ColorNum = 待绘制象素的颜色
;
; ES:DI = 屏幕基地址 (??? 我加上这个因为真的
; 不知道这里将发生什么事[RLH]).
;
; 退出: 没有有意义的值返回
;
dp textequ <dword ptr>
Color textequ <[bp+6]>
YPos textequ <[bp+8]>
XPos textequ <[bp+10]>
public SetPoint
SetPoint proc far
push bp
mov bp, sp
push di
les di, dp CurrentPage ; 指向活动VGA页
注3:许多旧时代的程序员认为汇编指令本应该大写显示。这样认为实事上是因为许多老式IBM大型机和少数像“Apple II”一样的个人计算机只支持大写字母。
mov ax, YPos ; 获取Ypos
mul ScreenWidth ; 获取行的开始偏移
mov bx, XPos ; 获取Xpos
mov cx, bx ; 保存平面计算
shr bx, 2 ; X 偏移 (bytes)= XPos/4
add bx, ax ; 偏移=Width*YPos + XPos/4
mov ax, MapMaskPlane1 ; 图形标志&平面选择寄存器
and cl, PlaneBits ; 获取平面位
shl ah, cl ; 获取平面选择值
out_16 SCIndex, ax ; 选择平面
mov al, Color ; 获取象素颜色
mov es:[di+bx], al ; 绘制象素
pop di
pop bp
ret 6
SetPoint endp
这里的许多改变完全是机械化的:将程序中的大写字母改为小写,将程序间隔地更好一些,对齐一些注释等等。但是,这些小小的、微妙的改变却对代码的易读性有很大的影响(至少,对一个经验丰富的汇编程序员来说是这样)。
1.3 S.COM 例子
下面的代码序列也是来自于在网上找到的一个名为“S.COM”的程序。
;获取所有匹配文件规范和配置表的文件名
GetFileRecords:
mov dx, OFFSET DTA ;设置 DTA
mov ah, 1Ah
int 21h
mov dx, FILESPEC ;获取第一个文件名
mov cl, 37h
mov ah, 4Eh
int 21h
jnc FileFound ;没有文件, 尝试一个不同的文件规范
mov si, OFFSET NoFilesMsg
call Error
jmp NewFilespec
FileFound:
mov di, OFFSET fileRecords ;DI -> 存储的文件名
mov bx, OFFSET files ;BX -> 文件数组
sub bx, 2
StoreFileName:
add bx, 2 ;所有文件都会改变,
cmp bx, (OFFSET files) + NFILES*2
jb @@L1
sub bx, 2
mov [last], bx
mov si, OFFSET tooManyMsg
jmp DoError
@@L1:
mov [bx], di ;存储在files[]中的status/filename指针
mov al, [DTA_ATTRIB] ;存储状态字节
and al, 3Fh ;高位用来指示文件已被标记
stosb
mov si, OFFSET DTA_NAME ;将DTA的文件名复制到文件名存储器中
call CopyString
inc di
mov si, OFFSET DTA_TIME ;复制时间、日期和大小
mov cx, 4
rep movsw
mov ah, 4Fh ;下一个文件名
int 21h
jnc StoreFileName
mov [last], bx ;保存指向最后一个文件入口的指针
mov al, [keepSorted] ;如果从EXEC返回, 需要重新拣选文件?
or al, al
jz DisplayFiles
jmp Sort0 这个程序的主要问题是格式化。标识符域与注解域重叠在一起(几乎每个实例都是这样),各种指令的操作数并没有对齐,也很少使用空格行来组织代码,程序员也过分地使用“局部操作符(译注:即[ ])”来标识名称,并且,虽然不普遍,但也有一些项目被大写了(记得,大写字母更难读)。这个程序也制造了相当可观的“magic numbers(译注:即让人看不明白实际意义的魔术数字)”,尤其在考虑传递给DOS的操作码上。
这个程序的另一个小问题是它组织控制流的方法。就在于代码中的一对指针,该对指针用来检查是否有一个错误存在(找不到文件和太多文件要处理)。如果有一个错误存在,作者把用来处理这些错误的分支代码放到了程序中间。不幸地,这样会打断程序的流程。许多读者都只想看一个流水线一样的程序操作而无需关心错误的处理细节。不幸地,作者为了必须跳过很少执行的代码而这样组织程序,这部分代码用来跟踪在公共环境里发生了什么(注4)。
这是上面程序稍微经过改良的版本:
;获取所有匹配文件规范和配置表的文件名
GetFileRecords mov dx, offset DTA ;设置 DTA
DOS SetDTA
; 获取第一个匹配指定文件名的文件(这可能包含通配字符)。如果没有该文件存在,
; 我们将获得一个错误
mov dx, FileSpec
mov cl, 37h
DOS FindFirstFile
jc FileNotFound
; 只要已经没有文件匹配我们的文件规范了 (也包含通配字符),我们就获取文件信息并
; 将它放到“files”数组里。
; 每次都通过 "StoreFileName" 循环我们都会通过调用DOS的FindNextFile函数来获
; 取一个新的文件名(第一次用FindFirstFile)。保存即将离开的文件信息并继续下一个
; 文件。 注4.注意, 我并不是说在这个程序中可以缺少这个错误检查/处理代码。 我只是提出在阅读代码时它不应该中断程序的正常流程。
mov di, offset fileRecords ;DI -> 用于存储文件名
mov bx, offset files ;BX -> 文件数组
sub bx, 2 ;为第一次循环做好环境
StoreFileName: add bx, 2
cmp bx, (offset files) + NFILES*2
jae TooManyFiles
; 存储在files[]数组中即将离去的status/filename指针。
; 注意状态字节中的 H.O. 位用来指出文件已被标记。
;
mov [bx], di ;存储在files[]中的指针
mov al, [DTAattrib] ;存储状态字
and al, 3Fh ;清除文件的标记位
stosb
;将DTA存储区域中的文件名复制到我们设置好的区域中。
mov si, offset DTAname
call CopyString
inc di ;跳过零字节 (???).
mov si, offset DTAtime ;复制时间、日期和大小
mov cx, 4
rep movsw
; 继续下一个文件并再试一次。
DOS FindNextFile
jnc StoreFileName
; 当处理完最后一个进来的文件后,做一些清理工作。
; (1) 保存最后一个进来的文件的指针
; (2) 如果从EXEC返回, 我就需要重选和显示文件。
mov [last], bx
mov al, [keepSorted]
or al, al
jz DisplayFiles
jmp Sort0
; 如果没有文件要处理就跳到这里
FileNotFound: mov si, offset NoFilesMsg
call Error
jmp NewFilespec
; 如果过多文件要处理就跳到这里
TooManyFiles: sub bx, 2
mov [last], bx
mov si, offset tooManyMsg
jmp DoError
这个改进过的版本省去了局部标识符,使用对齐所有的语句域和在代码中插入空行来将代码格式化的更好些。同时也清除了很多在之前版本中出现的大写字母。另一个改进是将错误处理的代码移出代码段的主程序流,这使得读者可以顺着程序执行流一直看下去。
1.4本文面向的读者
当然,如果一个汇编程序真的难读到没有人认得汇编语言了,创造其它语言当然是对的。在上面的例子中,如果你不懂得80x86汇编语言的话并不见得“改良过”的版本真的比原版本更易读。或许一个改良过的版本是我们通常理解上更有美感的版本。但是,如果你不懂80x86汇编语言的话也怀疑你对改良版本的理解会比原版本更多。除非在程序注释中嵌入80x86汇编语言的教程,否则决不会发生这种事情(注5)。
在上面的见解中,对“本文读者”一词的定义表示有意阅读汇编语言程序的朋友,希望都能这样做:
• 成为一个有相当实力的80x86汇编程序员。
• 努力解决汇编程序中的常见问题。
• 流利地阅读英文(注6)。
• 对高级语言原理有很好的领会。
• 拥有在计算机科学领域工作的适当知识(例如, 明白标准算法和数据结构,明白基本的计算机体系结构,和明白基本的离散数学)。
注5:当读者已经学过汇编语言后,做这些事(在程序注释中插入汇编语言的教程)会使得程序的可读性更差, 至少, 他们需要跳过这些教程;这也是他们读到的最坏情况(浪费他们的时间)。
注6:或者是你所在地方用来开发、处理、使用软件的任何一种自然语言。
1.5可读性标准
有人会问“如何使得一个程序比其它程序更易读?”,换句话说,我们应该如何来衡量一个程序的“可读性”?常规的度量,“我看到一个写得很好的程序”并不是适当的;对许多人来说,这也可以转换为“如果你的程序看起来跟我的好程序很像,那么它们就是易读的,或者它们没有比我的更好”。很明显,这种琐碎的评价标准会因人而异。
为了开发一个测量汇编语言程序可读性的标准,我们第一个要问的就是“为什么可读性如此重要?”这个问题有一个简单(虽然有点轻率)的答案:可读性很重要是因为程序是拿来读的(而且,一行代码被典型地阅读10次比写一行代码更常见)。进一步来说,考虑到许多程序都需要被其他程序员阅读和维护的事实(Steve McConnell 声称在一个真实的程序世界里程序员需要10次以上的代码维护工作,直到它们被重写;而且,他们算出在他们的工作中有60%的工作是花费在代码简单性上的)。你的程序更易读,其他人就可以用更少的时间来领会你的程序在做什么。也就是说,他们可以集中在增加新功能或纠正代码缺点上。
为了这份文档的目的,我们定义一个“易读”程序有下列特性:
•一个“易读”程序是一个能让有能力的程序员(一个熟悉解决程序问题的人)在没有看程序之前就能理解,而且能在最短的时间内就能完全理解整个程序。
那是个高要求!这个定义并不是说非常难达到,而一些不平凡的程序也确实达到了这个要求。该定义提出一个适当的程序员(也就是经常尝试解决程序问题的人)用他们常规的阅读方法阅读(只是一次)就能理解一个程序,并能完全理解该程序。任何小于该要求的都不是一个“易读”的程序。
当然,在现实中,当很少程序能达到这个目标后该定义就不能用了。部分问题是因为程序倾向于相当长和需要一些人有在同一时间里在他们脑中管理大量细节的能力。而且,也不晓得一个写的好的程序会是怎样的,“一个有能力的程序员”也没有提出程序员的IQ需要高到阅读一个语句就能完全明白它的意思而无需想太多。因此,我们必须定义可读性,这并不是一个真假事实,而是一个衡量范围。尽管存在真正难读的程序,也有许多“易读”程序比其它程序难读的情况。因此,或许下面的定义更现实一点:
•一个由一个或多个模块组成的易读程序。符合要求的程序能做到随便选取程序中的一个模块,对程序中的每条语句平均花费不到1分钟就能理解80%。
80%的理解程度意味着程序员能修正程序的错误和增加程序的功能而不会因为对手头上代码的误解而犯错误。 1.6怎样做到可读性
可读程序的“我看见一个就明白一个”的度量提供了一个关于如何编写可读性好的程序的提示。在指明之前,“当我看见它就明白它的意思”的度量暗示了如果这个程序跟那个特定的人所写的(好)程序很相像,那他会个人认为这个程序可读性好。这也暗示了一个重要特性-可读性程序必须具备:一致性。如果所有的程序员都使用一致的风格来编写程序,他们会发现其他人写的程序跟自己的很相似,而且,因为这样而更易读。这个目标也是这篇文章的主要目的-建议一个所有人都会跟随的一致性标准。
当然,一致性本身并不足够。一致性不好的程序也特别难读。因此,当个人考虑定义一个包含所有标准的准则时必须特别小心。这篇文章的目标就是创建这样一个标准。无论如何,不要因为在此时听起来不错或因为一些个人喜好而觉得出现在这篇文章中的资料很简单。这篇文章中的资料都是来自几个主题上的软件工程原文(包括《单元编程风格》、 《代码大全》、和《编写可靠代码》(译注1)),来自将近20多年的个人汇编语言编程经验,和一套从信息管理系研究得出的常规编程准则也包括在内。
这份文档假定它的读者有一致的使用习惯。因此,它集中了许多会影响一个程序可读性的机械化的和心理上的论点。例如,大写字母要比小写字母难读(在心理学上是众所周知的结果)。它要花费人们较长的时间来认识大写字母,所以,一般人都要花费更多的时间来阅读全部用大写字母写的文章。因此,该文档建议在程序中避免使用大写字母串。许多出现在该文档的其它论点也使用相似的方式告知;这些论点建议你在写程序时可能要作些小更改来使得它更容易被其他人理解你的代码结构,以此来帮助理解程序。 1.7这份文档的组织
这份文档按照自顶向下的顺序来讨论可读性。从程序的概念开始,然后讨论模块,剩下来的过程就是从那里开始以自己的方式工作。文档会讨论个别的语句。更进一步,它会讨论组合成语句的各部分(例如:指令、名称和操作数)。最后,这份文档会以讨论一些正面问题来作为结束。
第二节从广义上讨论程序。它主要讨论必须跟一个程序和组织中的源文件在一起的文档。该节也简短地讨论配置管理和源代码控制问题。记住,领会如何建造一个程序(编写、汇编、链接、测试、调试等等)是很重要的。如果你的读者完全明白你在使用“堆排序”算法,但并不能建一个可执行的模型来运行,那么他们仍然未能完全明白你的程序。 译注1:中文书名纯粹是我个人翻译的。包括:《Elements of Programming Style》,《Code Complete》, and 《Writing Solid Code》,我在网上查了一下,国内只出版了《Code Complete》的译本,名为《代码大全》,其它的找不到中译本,只搜索到有繁体版的《Writing Solid Code》,名为《完美程式设计指南》
-jhkdiy
第三节论述如何用合理的方法在你的程序中组织模块。这使得其他人更容易定位代码区域和组织彼此关联的代码区域。因此使得人们在努力理解你的程序行为的时候更容易找到重要的代码和忽略次要的或不关联的代码。
第四节讨论程序中的过程使用。这是第三节主题的一个延续,虽然在一个更底层、更详细的级别。
第五节在语句级别上讨论程序。这个(大)节提供了该建议的内容。这份文档给出的许多规则都出现在该节中。
第六节论述构成一个语句的各个项(标识、名称、指令、操作、操作数等等)。这是另一个给出在编写可读性程序时大量规则可遵循的大节。该节会讨论名称转换、不适当的操作数等等。
第七节讨论数据类型和其它相关主题。
第八节覆盖了前几节中未能覆盖的其它主题。
1.8指导、 规则、强制性规则、和例外
并不是所有规则都同等重要。例如,一个在注释中检查所有文字拼写的规则可能就比不上建议所有注释都用英语来写重要(注7)。所以,这份文档使用三个指示来保持事情的统一:指导、规则、和强制性规则。
一个指导就是一个建议。这是一个你应该遵循的规则,除非你能辩护为什么你要违反该规则。即使是一个好的,可辩护的,前提是你不会因违反一个准则而感到困惑。指导的存在是为了鼓励该领域的一致性而没有更好的理由来选择其它方法。你不能只是因为不喜欢它而违反一个准则―做这些事会使得你的程序与其它遵循该准则的程序并不一致(而且,因此而更难读―无论如何,你不要因为你违反了一个准则而失眠^_^!)
规则比指导更严。你不能违反一个规则除非有一些外部原因要这样做(例如,因要调用一个库程序而强制你使用一个坏的名称转换)。无论你什么时候觉得必须违反一个规则,你必须检验至少两个伙伴中的一个在审阅之后认为这是合理的。而且,你应该要在程序注释里解释为什么这里要违反规则。规则仅仅是―规范遵循它的人。无论如何,确实是存在必须违反规则来满足外部需求或者是使得程序更易读的情况。
强制性规则是最为严格的。你绝不能违反一个强制性规则。如果真正需要这么做,那你就要考虑在将强制性规则降为一个简单规则与妥协该违反之间做一个合理的选择。
一个例外正好是说,一个大家知道通常都会违反一个指导、规则、或(很极端)强制性规则的例子。尽管例外事件很少见,“每个规则都有它的例外情况…”的老格言无疑也适用这份文档。例外情况指出一些能预先了解到的公共违反情况。
当然,在这里对指导、规则、强制性规则和例外情况的分类只是个人意见。在一些组织里,这个分类可能要求依赖该需求的组织重新制定一个分类。
----------------------------------------------------------------------------
注7.如果不是英语的话你可以用你区域的本地语言来代替。
1.9 涉及的语言
这份文档将假定所有程序都是用80x86汇编语言来编写的。尽管这样组织在商业应用程序中很少见,这个假定也会绝不违反这些指导。其它存在于各种高级语言的准则(包括该文档作者所写的一组)。你可以为你使用的其它语言采用合适的准则而在程序的80x86汇编语言模块中采用本文档的准则。 2.0程序组织
一个源程序通常都包含一个或多个源代码文件、目标文件和库文件。随着项目的变大,文件的数量也会一起增长,这样下去很难在一个项目中保持对文件的跟踪。如果是多个不同的项目共享一组代码模块的话更是如此。该节就专注于讨论这些相关的话题。
2.1库函数
库文件,由于它很普遍,建议稳定性。忽略软件过失的可能性,有人非常希望一个库文件中的函数或程序可以用于不同的项目。一个好例子就是“80x86汇编程序员的UCR标准库”。有人希望使用标准库可以使得“printf”在不同的程序里都有同样的表现。形成对比的是在两个程序中,每个printf实现都有它们自己的版本。有人不能合理地假设两个程序有同样的实现(注8),这就带出了下面的规则:
规则: 库函数是那些被设计为可在许多不同的汇编语言程序中公共重用的程序。在系统上的所有汇编语言库(可调用)都会存在“.lib”的文件和出现在“/lib”或者是“/asmlib”子目录里。
指导: 如果你使用多种语言而当这些语言可能需要在“/lib”目录里存放文件时“/asmlib”或许是更好的选择。
例外: 当许多人希望UCR标准库的“stdlib.lib”文件离开“/stdlib/lib”目录时它可能是合理的。
上面的规则确保库文件都在同一个位置以使得它们容易被找到。修改和检查,将你所有的库模块都放到单一目录里,你避免了诸如用一个过时的库链接一个程序而使用更新版的库链接另一个程序的配置管理问题。 注8:实事上,只是相对来说正确。如果两个实现都是一样的那个人应该明白。这将告诫程序作者中一部分有不良设计的成员,你必须对两个不同程序中的同一个代码片段进行维护。
2.2公共目标模块
该文档定义一个库是一个在许多不同程序中有广泛应用的目标模块集合。UCR标准库就是一个库的典型示例。有些目标模块没有通用的目的,但仍然可以在两个或多个不同的程序中找到它的应用。这种情况存在两个主要的配置管理问题:(1)确认“.obj”文件在链接一个程序时是最新的;(2)为了能验证一个模块的改变而需要了解那些模块使用了其它模块是不能中断现存的代码的。
下面的规则照顾第一种情况:
规则: 如果两个不同的程序共享一个目标模块,那么那个模块和该模块关联的源文件、目标文件、和makefile文件应该存放在一个子目录里(也就是说,其它文件不能在该子目录里)。子目录名应该和模块名同名。如有可能,你应该创建一组链接/别名来快速连接到这个子目录并将这些链接放置在每个工程的主目录中以便应用这些模块。如果不可能使用链接,你应该将模块的目录放置在“/common”子目录里。
强制规则:每个子目录包含一个或多个模块时应该有一个makefile来自动生成适当的、最新的“.obj”文件。为了能简单地执行make程序,一个独立文件,一个批处理文件,或另一个makefile应该能自动生成新目标模块(如有需要)。
指导: 可以使用微软的nmake程序。最低限度,在你的makefiles文件中使用nmake合法的语法。
另一个问题,要关注那个工程使用了给予的模块是很困难的。明显的解决方法是,注释跟模块关联的源代码来告诉读者那个程序使用了模块,但这不实用。维护这些注释有太多的错误倾向并且注释会很快地从程序段中除去,而且会坏到无法使用的地步--它们会不正确。一个更好的解决方法是使用模块名来创建一个带“.elw”后缀的伪文件,并且将该文件放到每个程序的主目录中以便链接该模块。现在,使用一个古老的“where is”程序,你可以很容易地找出使用该模块的所有的工程。
指导: 如果一个工程使用了一个没有在本工程子目录里的模块,创建一个使用模块名并带有“.elw”后缀的伪文件(使用“TOUCH”或一个兼容程序)。这将使得人们通过使用“where is”程序很容易地就能找出使用一个公共目标模块的所有工程。 2.3局部模块
局部模块是那些被单一程序/工程使用的模块。典型的,每个模块的源文件和工程文件都和其它跟工程关联的文件一样放在同一个目录里。这是一个合理的方案直到文件的数目增加到难以在一个目录列表里找出一个文件来。对于这种情况,许多程序员开始使用创建子目录的方法来重新组织他们的目录以便容纳这些为数众多的源模块文件。无论如何,这些新子目录的布局、名称、和内容都会对程序的整体可读性有较大的影响。该节就将讨论这些问题。
第一个问题就是考虑这些新子目录的内容。当程序员将来在这个工程里查找文件的时候需要很容易就能在工程里找到这些源文件,为了能很容易地在你放进该目录的文件中找出源文件,如何组织这些新子目录就显得尤为重要了。最好的组织方式就是将每个源模块(或一小组紧密相关的模块)放进它们各自的子目录里。子目录接受源模块除去它后缀名后的名称(如果在一个子目录里多于一个的话就按主模块命名)。如果你放置了两个或多个源文件在同一目录里,要确保这组源文件是有粘性的一组(意思就是这些源文件都是用来解决单一问题的代码)。关于粘聚性的讨论会在后面章节中出现。
规则: 如果一个工程目录里包含太多的文件,尝试将一些文件移到工程目录的子目录里;将子目录名改为源文件名除去后缀名后的名称。这几乎会减少文件数目的一半。如果这个简化还不够,尝试对源模块进行分类(例如:文件IO、图形、示意图、声音)并将这些文件移到该子目录下,用类别名来命名子目录。
强制性规则:你创建的每个新子目录都要有它自己的makefile文件以便能自动汇编该目录下的所有源模块,适当的方式。
强制性规则:你为这些源模块创建的任何新子目录都应该包含在工程目录里。只有在你预先设定这些模块会和其它工程共享时除外。要了解更详细的资料请看13页的“公共目标模块”。
单一的汇编语言程序通常都包含一个“main”过程―当操作系统将程序载入内存后的第一个执行单元。对于一个工程的任何新程序员来说,该过程是大家首先开始阅读的地方,也是读者会经常参考的地方。因此,读者应该可以很容易地找到这个源源文件。下面的规则就是帮助读者确认这种情况:
规则: 包含main程序的源模块应该与执行文件的名称相同(后缀名明显是不同的)。例如,如果“Simulate 886”程序的执行文件名是“Sim886.exe”,那么你就可以在“Sim886.asm”源文件中找到main程序。 查找包含main程序的源文件是一回事。查找main程序本身却可能很困难。汇编语言允许你给main程序命名任何你想要的名称。无论如何,要使得main程序容易找出来(在源代码层和在O/S层都一样),你应该确确实实将这个程序段命名为“main”。看第15页的“模块组织”可以了解更多关于main程序安排的资料。
规则: 汇编程序中main过程的名称应该命名为“main”。 2.4程序的make文件
每一个工程,甚至它只包含一个单独模块的话,都会有一个关联的make文件。如果有人想汇编你的程序,他们会担心使用那个汇编程序(例如,MASM)、使用那些命令行选项等等。他们可能会键入“nmake”(注9)来生成一个执行程序。即使是汇编一些程序中包含的无关紧要的汇编名称和源文件名称,你应该要有个make文件。一些人甚至没有认识到人们所要的就是该文件。
强制性规则:主工程目录里必须包含一个make文件以便在简单的make/nmake命令响应下自动生成一个可执行文件(或其它要求的目标模块)。
规则: 如果你工程中使用的目标模块跟main程序模块不在同一个子目录下,如果目标代码过期后你应该测试这些模块的“.obj”文件并执行它们目录下的相应的make文件。你能假定库文件是最新的。
指导: 避免使用古怪的“make”特性。许多程序员只学习了make的一些基础知识,如果你完全采用make语言的话很可能造成别人不明白你的make文件在做什么。如果一些人任意在包含make文件的目录下增加或删除文件的时候特别要避免使用默认的规则,这可能会制造麻烦。 注9:或者是你经常使用的其它make程序
3.0 模块组织
一个模块是一组有逻辑关系的对象的集合。这些对象可能包括常数、数据类型、变量和程序单元(例如,函数、过程等等)。注意对象在一个模块中无需物理相关。例如,完全有可能使用几个不同的源文件来构造一个模块。同样,也完全有可能在同一个源文件中就有好几个模块。无论如何,最好的模块无论在逻辑上和物理上都是相关的;也就是说,所有对象都跟单个源文件(如果源文件太大或许就是目录)中存在的一个模块有关联并不存在其它东西。
模块容纳几个不同的对象,包括常数、类型、变量和程序单元(例程)。模块共享例程(程序单元)的许多属性;当例程是一个特定模块的主要部件时这并不感到意外。无论如何,模块都应该有它自己的一些附加属性。下面几节将讨论一个编写良好的模块所具有的属性。
注意:单元和包在模块术语中是同义词
3.1模块属性
一个模块是一个在常规术语中用来描述一组有相关对象的程序(程序单元以及数据和类型对象)以某种方式连接在一起。好模块共享许多好程序单元的同一属性以及这些属性会在模块外部隐藏代码细节。
3.1.1模块内聚性
模块展示了下面几种内聚性(从好排到坏):
• 如果模块正好完成一个(简单)的任务存在功能内聚或逻辑内聚。
• 在一个“filter-like”方式中一个模块需要从一个操作中获取数据供给下一个操作的确定顺序工作时,该模块需要做几个连续的操作。这时应该存在顺序内聚或流水线内聚。
• 当一个模块对一组公共数据进行一组操作时存在全局内聚或通讯内聚,但是其它没有联系。
• 当一个模块执行一组需要在同一时间(但不需要同一顺序)完成的操作时存在临时内聚。一个典型的初始化模块就是这种代码的一个例子。
• 当一个模块在指定的顺序里完成连续的操作时使用过程内聚,它们必须完成的顺序将它们彼此邦定在一起是唯一一件事。不像顺序内聚,这些操作没有共享数据。
• 当有几个不同(没有关联)的操作出现在同一个模块和一个状态变量选择这些操作来执行时会发生状态内聚。有些模块典型地包含一个case(switch)或if..elseif..elseif...语句。
• 如果在一个模块中的操作没有明显关联到其它操作的话不存在内聚性。
上面的前三个内聚性列表通常在一个程序中是可接受的。第四个(临时内聚)多半会没意见,但可能极少用它。最后三个列表应该从来不会出现在程序中。要想了解模块内聚性的一些例子,可以参考《Code Complete》(译注:国内有最新的第二版中译本)。 指导: 设计优良的模块!好模块展示出强壮的内聚性。也就是说,一个模块应该提供一(小)组逻辑相关的服务。例如,一个“printer”模块应该提供一个打印机想要的所有服务。一个模块中的个别程序便会提供个别服务。 3.1.2模块耦合性
当两个模块相互通讯时就可以参考耦合方式。定义两个模块之间的耦合程度有几个标准:
• 基数-两个模块之间通讯的对象数目。越少对象越好(也就是较少参数)。
• 紧密度-怎样进行“私有”通讯?参数列表许多是私有列表;在一个类中或一个对象中的私有数据域则是其次;类中或对象中的公有数据域又是一层,全局变量甚至更没有亲密性,而在一个文件中或数据库中传递数据则是最少紧密度的。编写良好的模块展示出高度的亲密性。(译注:也就是让调用者感觉不到它们在通讯)。
• 可见性-这是稍微在紧密度上相关的,这参考了当你在两个模块之间传递时数据在整个系统中的可见性。在一个参数列表中传递数据是直接和易见的(你始终都能看见调用者将数据传递给被调用程序);在一个全局变量中传递数据来通讯的话可见性就较差了(在程序调用之前你必须长时间准备全局变量)。另一个例子就是传递简单(单项)变量而不是传递一批变量给一个结构体/记录并将那个结构体/记录传递给被调用者。
• 灵活性-这参考了怎样使两个程序通讯更容易以便不用像最初那样彼此调用。例如,假定你传递了一个包含3个域的结构体给一个函数。如果你想调用那个函数但是你只有3个数据对象,而不是一个结构体,那你会创建一个结构体,将3个值赋给那个结构体的各个域,然后才调用函数。另外一种方法,你可以简单地传递3个值为分开的参数,你同样可以和传递分开的值一样传递一个结构体(已填充每个域)给调用函数。模块包含后面这个函数就更具灵活性了。
一个模块如果它的函数集很少就可以使用松散耦合,高紧密度,高可见性,和高度灵活性。通常,这些特性都会彼此冲突(例如,分开一个结构体的各个域可以增加灵活性[好事情]但也会增加函数数目[坏事情])。工程师们的传统目标都是在每个实际的环境中选择一个适当的折中方案;因此,你必须小心平衡上面四个属性中的每一个。
使用松散耦合的模块通常包含较少的KLOC(每一千行代码)错误百分比。而且,松散耦合的模块更容易被重用(当前和将来的工程都可使用)。要了解更多松散耦合的信息,请查看《Code Complete》的有关章节。(译注:说的那么好真想买一本看看,^_^)。 指导: 设计优秀的模块!好模块展示出松散耦合(译注1)。也就是说,在模块与外界之间只有很少的、已定义好的(可见的)接口。许多数据是私有的,可访问性只能通过接口函数(看看下面的信息隐藏)。因此,接口应该具备灵活性。
指导: 设计优秀的模块!好模块展示出信息隐藏。模块外部的代码应该只能访问模块内的一小组公共程序。那个模块的所有数据应该是私有的。一个模块应该实现一个抽象数据类型。模块的所有接口应该是一组已定义好的操作。
3.1.3模块的物理组织
许多语言都对模块提供了直接的支持(例如,Ada中的包,Modula-2中的模块,和Delphi/Pascal中的单元)。一些语言就只提供了对模块的间接支持(例如,C/C++中的一个源文件)。其它的,像BASIC,不真正支持模块,所以你要将相互关联的对象进行物理分组来模仿它们并做一些练习。汇编语言位于它们中间。从其它模块中隐藏名称的主要方法是为单独的源文件实现一个模块并只公布那些用来通向外界模块接口的那部分名称。
规则: 每一个模块都应该完全驻留在单个源文件中。如果大小考虑防止了该规则,那么一个给定模块的所有源文件都放在模块指定的子目录里。
有些人有将每个函数分开到每个源文件中疯狂模块化的想法。这种物理模块分发不但没有帮助反而会削弱一个程序的可读性。努力用逻辑模块化来代替,也就是说,为它的操作定义一个模块更胜于为源代码语法定义一个模块(例如,将函数分离开来)。
该文档并不探讨如何将一个问题分解为它自己的模块化组件。大概,你早已学会处理这部分任务了。如果你感觉对这部分知识贫乏的话有各种各样关于该主题的文章可参考。
3.1.4模块接口
在支持模块的任何语言系统里,一个模块都有两个主要的部分:公布模块可视名称的接口部分和包含实际代码、数据、和私有对象的实现部分。MASM(和许多编译器)使用了跟C/C++非常相似的方案。有伪指令让你导入和导出名称。像C/C++,你可以直接在有关联的模块中放置这些伪指令。不管怎样,这样的代码很难维护(无论你什么时候修改了一个公共名称你都必须在每个文件中修改伪指令)。解决办法是,跟在C/C++语言中采用的一样,使用头文件。头文件包含所有公共的定义和输出(同样包括公共数据类型定义和常数定义)。头文件为想使用模块中的现存代码的其它模块提供了接口。
译注1:和前面说的模块高内聚是一体的,这也是软件工程学里常说的:“模块要高内聚低耦合”。
MASM6.x的externdef伪指令完全是为了创建接口文件的。当你在一个源模块中使用了externdef时也就定义了一个符号,externdef的表现很像public指示,导出名称到其它模块。当你在一个源模块中使用了externdef时也引用了一个扩展名,externdef的表现也像extern(或 extrn)伪指令。这让你可以在单个文件中放置一个externdef
和在两个导入和导出公共名称的模块中包含这个文件。
如果你使用了一个不支持externdef的编译器,你可以考虑切换到MASM6.x。如果切换到一个更好的编译器(支持externdef)是不可行的话,你要做的最后一件事就是在几个分离的文件中维护你的接口信息。作为替换,如果模块的名称符号是已预先定义在一个头文件的话,那么可以使用编译器的ifdef条件汇编伪指令来汇编一组在头文件中的公共语句。尽管你仍然需要在两个地方维护公共和外部信息(在ifdef为真的区域和为假的区域),但它们在同一个文件里而且彼此都容易找到对方。
规则: 为指定的模块在单个头文件中保留所有模块接口伪指令(public、extrn、extern和externdef)。最好也将所有其它公共数据类型定义和常数定义放到这个头文件里。
指导: 任何一个模块都应该只关联一个头文件(即使模块有多个源文件跟它关联)。如果,为了一些原因,你感觉需要有多个头文件来关联一个模块,你应该创建一个单独包含所有其它接口文件的文件。这种方式使得一个想要使用所有头文件的程序仅仅需要包含这个单独的文件。
当设计头文件时,确保你能包含一个文件多次而不会产生坏影响(例如,符号重复错误)。做这种工作的传统方法是像下面这样用一个IFDEF语句来包围头文件中的所有语句:
; Module: MyHeader.a
ifndef MyHeader_A
MyHeader_A = 0
.
. ;Statements in this header file.
.
endif
当一个源文件第一次包含“MyHeader.a”时“MyHeader_A”符号还没有定义。因此,汇编程序会处理头文件中的所有语句。当再次包含操作时(在同样的汇编期间)“MyHeader_A”符号就已经被定义,所以汇编程序会忽略整个include文件。 我想你包含一个文件两次吗?这容易。一些头文件可能包含在其它头文件里。一个模块包含了“YourHeader.a”文件也很可能包含了“MyHeader.a”(假定“YourHeader.a”包含了适当的伪指令)。你的主程序,包含“YourHeader.a”也可能需要“MyHeader.a”因此它明确包含该文件而不晓得“YourHeader.a”已经处理了“MyHeader.a”,因此引起符号重定义。
规则: 始终放置一个适当的IFNDEF语句来包围在一个头文件中的所有定义,以便允许多次包含头文件而不会有坏影响。
指导: 为汇编语言头文件/接口文件使用“.a”后缀名。
规则: 为库函数包含文件系统上应该存在“.a”文件和应该出现在系统的“/include”或“/asminc”子目录里。
指导: 如果你使用多种语言当这些语言可能需要在“/include”目录里放置文件时“/asminc”或许是更好的选择。
例外: 当人们期望UCR标准库的“stdlib.a”文件离开“/stdlib/include”目录时或许是合理的。 4.0程序单元组织
一个程序单元是任何的过程、函数、协同程序、迭代程序、子程序、子程序段、例程或是其它用来描述一段在计算机上抽象出来的一组公共操作的术语。该文会简单地用术语过程或例程来描述这些概念。
例程是跟模块紧密相关的,因为它们都倾向于成为一个模块的主要组成部分(连同数据、常数和类型)。因此,许多适用于模块的属性也同样适用于例程。下面的段落,是在存在冗余信息的情况下重复之前的定义,所以你不必回头去看前面的章节。
4.1例程内聚性
例程展示出下面几项内聚性(从好排到坏):
• 如果一个例程刚好完成一个任务则存在功能或逻辑内聚。
• 在一个“filter-like”方式中一个例程需要从一个操作中获取数据供给下一个操作的确定顺序工作时,该例程需要做几个连续的操作。这时存在顺序内聚或流水线内聚。
• 当一个例程对一组公共数据进行一组操作时存在全局内聚或通讯内聚,但是其它没有联系。
• 当一个例程执行一组需要在同一时间(但不需要同一顺序)完成的操作时使用临时内聚。一个典型的初始化例程就是这种代码的一个例子。
• 当一个例程在指定的顺序里完成连续的操作时存在过程内聚,它们必须完成的顺序将它们彼此邦定在一起是唯一一件事。不像顺序内聚,这些操作没有共享数据。
• 当有几个不同(没有关联)的操作出现在同一个模块和一个状态变量(例如,一个参数)选择这些操作来执行时会发生状态内聚。有些模块典型地包含一个case(switch)或if..elseif..elseif...语句。
• 如果在一个例程中的操作没有明显关联到其它操作的话不存在内聚性。
上面的前三个列表通常都在一个程序中是可接受的,第四个(临时内聚)也没什么问题,但你应该尽量不使用它。最后三个列表应该从来不会出现在程序中。要想了解模块内聚性的一些例子,可以参考《Code Complete》。
指导: 所有的例程都应该展现出良好的内聚性。功能内聚是最好的,其次是顺序内聚和全局内聚,发生临时内聚也没多大问题。但应该避免其它内聚。 4.1.1例程耦合性
当两个例程相互通讯时就可以参考耦合方式。定义两个例程之间的耦合程度有几个标准:
• 基数-两个例程之间通讯的对象数目。越少对象越好(也就是较少参数)。
• 紧密度-怎样进行“私有”通讯?参数列表许多是私有列表;在一个类中或一个对象中的私有数据域则是其次;类中或对象中的公有数据域又是一层,全局变量甚至更没有亲密性,而在一个文件中或数据库中传递数据则是最少紧密度的。编写良好的例程展示出高度的亲密性。(译注:也就是让调用者感觉不到它们在通讯)。
• 可见性-这是稍微在紧密度上相关的,这参考了当你在两个例程之间传递时数据在整个系统中的可见性。例如,在一个参数列表中传递数据是直接和易见的(你始终都能看见调用者将数据传递给被调用程序);在一个全局变量中传递数据来通讯的话可见性就较差了(在程序调用之前你必须长时间准备全局变量)。另一个例子就是传递简单(单项)变量而不是传递一批变量给一个结构体/记录并将那个结构体/记录传递给被调用者。
• 灵活性-这参考了怎样使两个程序通讯更容易以便不用像最初那样彼此调用。例如,假定你传递了一个包含3个域的结构体给一个函数。如果你想调用那个函数但是你只有3个数据对象,而不是一个结构体,那你会创建一个结构体,将3个值赋给那个结构体的各个域,然后才调用函数。另外一种方法,你可以简单地传递3个值为分开的参数,你同样可以和传递分开的值一样传递一个结构体(已填充每个域)给调用函数。 如果一个函数的数目很少就可以是松散耦合的,但高内聚,高可视性和高度灵活性。通常,这些特性都会与其它特性相冲突(例如,分开一个结构体的各个域可以增加灵活性[好事情]也会增加函数数目[坏事情])。工程师们的传统目标都是在每个实际的环境中选择一个适当的折中方案;因此,你必须小心平衡上面四个属性中的每一个。
一个使用松散耦合的程序通常包含较少的KLOC(每一千行代码)错误百分比。而且,松散耦合的例程更容易被重用(当前和将来的工程都可使用)。要了解更多松散耦合的信息,请查看《Code Complete》的有关章节。
指导: 在源代码中耦合的例程应该要松散。 4.1.2例程大小
1960年的时候,有些人认为程序员在一个时间里只能阅读一页代码,因此例程应该是一整页的长度(当时是66行)。在1970年,当交互计算流行起来时,该规则又调整为24行?一个终端屏幕大小。事实上,只有很少的经验证据来建议缩小例程大小是一个好特征。事实上,在对几个包含人为限制例程大小的代码研究中发现??较短程序通常包含更多的KLOC错误百分比(注10)。
一个展示出功能内聚的例程即是正确的大小,几乎不关它包含代码行数的事。你不应该仅仅因为你感觉一个例程太长了而人为地将一个例程中断为两个或更多的例程(例如,sub_part1 和 sub_part2)。首先,检验你的例程展现出了强内聚和低耦合,如果是这种情况,例程就没有过长。时刻记住,无论如何,一个长例程的明显象征是它完成几个操作并因此没有展现出强内聚。
当然,你可以将代码写的很长。许多在该主题上的研究指出例程超出150-200行之后会倾向于包含更多的bug和要比短例程花费更高的修正代价。注意,顺便说的,当计算一个程序中的代码行数时不应该将空行和纯注释行计算在内。
也要注意不要对涉及处理高级语言的例程大小有过多的研究。一个对比的汇编语言例程通常都比相应的高级语言例程包含更多的代码行。因此,你能在汇编语言中期望你的例程成为较短的一个。
指导: 不要让人为限制影响了你的例程大小。如果一个例程超过了大约200-250行代码,确认该例程展现出了功能和顺序内聚。也要看一下如果没有一般的后续在你的代码中那你就能转换为独立的例程。
规则: 永远不要使用将例程分开为几部分的做法来缩短一个例程,那样做你需要始终使用一个合适的顺序来调用缩短的原程序。
注10:发生这种事是因为较短函数一定有强壮的耦合性,导致了集成错误。 4.2主过程和数据的安排
像之前的解释那样,你应该命名主过程为main并将它的源代码文件和执行文件的名字命名为相同的名称。如果这个模块实在是很长,那么寻找main程序仍然很困难。一个好的解决办法是始终将main过程放在源文件的同一个位置。被约定(每个人都期望这种方式),在汇编语言里许多程序员将它们的main程序设为第一个或最后一个过程。两种位置都很好。将main程序放在其它地方会使main过程难于查找。
规则: 始终将mian过程设为源文件中第一个或最后一个过程。
MASM,因为它是一个多相汇编器,不需要在你使用一个标志符之前定义它。这有必要是因为许多指令(如JMP)需要参考到在程序稍后找到的标志符。在一个相似的方法中,MASM并不关心你在那里定义你的数据-在它使用之前或之后(注11)。不管怎样,许多程序员用高级语言形成了在首次使用一个标识符之前要先定义它。结果是,它们期望在源文件里回头查看能找到一个变量定义。自从所有人都希望如此后,在汇编语言里它仍然是一个好传统。
规则: 在汇编语言程序里你应该在使用所有变量、常数、和宏之前先声明它们。
规则: 你应该在源模块的开始处声明所有的静态变量(你定义在段里的那些)。 5.0语句组织
在一个汇编语言程序里,作者必须要做额外的努力来使得一个程序易读。在遵循了大量规则之后,你也能写出一个易读的程序。无论如何,不管你遵循了多少其它规则,只要你破坏了一个单独的规则,你都会使一个程序难于阅读。该规则就是你如何在你的程序中组织你的语句,该规则无论在什么地方都更实际的。考虑下面从“Art of Assembly Language program”摘来的例子:
微软的宏汇编器是一个自由方式的汇编器。各种各样的汇编语句可以出现在任何的列里(只要它们的出现顺序适当)。任意数量的空格或Tab键都能分隔语句的各个域。对汇编器来说,下面的两个代码序列都是一样的: 注11:技术上,这是不正确的。在一个程序中如果你在使用变量之前先定义它们在一些特殊情况下MASM会生成更好的机器码。
______________________________________________________
mov ax, 0
mov bx, ax
add ax, dx
mov cx, ax
______________________________________________________
mov ax, 0
mov bx, ax
add ax, dx
mov cx, ax
______________________________________________________
第一个代码序列比第二个更加易读(如果你不那么认为,或许应该去看看医生!)。从可读性来考虑,在你的程序中间隔的合理使用能改变世界上的所有差别。
当这是一个极端的例子时,要注意这仅仅是对一个程序可读性有大影响中的一小部分。考虑早前给出的一个例子(一小节): GetFileRecords:
mov dx, OFFSET DTA ;安装 DTA
mov ah, 1Ah
int 21h
mov dx, FILESPEC ;获取第一个文件名
mov cl, 37h
mov ah, 4Eh
int 21h
jnc FileFound ;没有文件。尝试不同的文件表。
mov si, OFFSET NoFilesMsg
call Error
jmp NewFilespec
FileFound:
mov di, OFFSET fileRecords ;DI -> 保存文件名
mov bx, OFFSET files ;BX -> 文件数组
sub bx, 2 改进过的版本:
GetFileRecords: mov dx, offset DTA ;安装 DTA
DOS SetDTA
mov dx, FileSpec
mov cl, 37h
DOS FindFirstFile
jc FileNotFound
mov di, offset fileRecords ;DI -> 存储文件文件名
mov bx, offset files ;BX -> 文件数组
sub bx, 2 ;为第一次重复指定条件
一个汇编语句由四个可能的域组成:一个标号域,一个助记符域,一个操作数域、和一个注释文本。助记符和注释域一直都是可选的。虽然某些指令(助记符)不允许标号,但当其它地方需要标号时标号域通常也是可选的。紧跟在助记符域的是操作数域。对于许多指令,实际的助记符决定了那个操作数域必须被传递。
MASM是一个自由方式的汇编器,它不要求这些域要出现在任何具体的列里(注12)。不管怎样,可以使用任意方式自由排列这些域是其中一个使汇编语言难于阅读的主要“贡献者”。尽管MASM让你自由输入你的程序,但这里完全没有原因让你不采用一个固定的字段格式,始终在同样的例里开始每个域。这样做通常能帮助一个汇编程序更加易读。这里有些你应该使用的规则:
规则: 如果一个标识符出现的标号域里,始终在源代码行里的那列开始这个标识符。(译注:即要对齐)
规则: 所有的助记符都应该在同一列里开始。通常,这应该是在列17位置(第二个Tab停止位置)或者是其它一些方便的位置。
规则: 所有的操作数都应该在同一列里开始。通常,这应该是在列25位置(第三个Tab停止位置)或者是其它一些方便的位置。
例外: 如果一个助记符(特别是一个宏)长到超过7个字符并需要一个操作数,那你没有选择但是可以在超过列25(这是一个例外,假定你为你的操作数分别选择了列17和列25)的地方开始操作数域。
指导: 尝试始终在源代码行中的同一列里开始注释域(注意始终在整个程序的同一列里开始注释域是不切实际的)。
注12:其它机器的老式汇编器需要标号开始在第一列,助记符出现在一个指定的列,操作数出现在一个指定的列,等等。这些都是固定格式源码翻译器的例子。
许多人在学习汇编语言之前都学过一种高级语言。他们已经被根深蒂固地教导为可读性(高级语言)程序有它们的控制结构来适当地缩进显示一个程序结构体。当你有一种基于单元结构的语言时缩进工作的很好。汇编语言,无论如何,是一种原始非结构化的语言,一个结构化编程语言的缩进规则并不能直接用上去。当可能要指出一些特殊指令的确定顺序(例如,一个if..then..else..endif语句中属于“then”的那部分)时就很重要了,在汇编语言程序中缩进并不是实现该目的适当方法。
如果你需要从周围的代码中分开一段语句,在你的源代码中使用空行是最好的。为一小段语句分离,可以为范例从另一个分开一个计算,单个空行就足够了。要真正显示指定代码的一节,使用两行、三行、或甚至是4行空行来从周围代码中分离出一块语句。要从代码中分离令各完全无关的小节,你可能要使用好几个空行和一行虚线或星号来分开语句。例如:
mov dx, FileSpec
mov cl, 37h
DOS FindFirstFile
jc FileNotFound
; *********************************************
mov di, offset fileRecords ;DI -> 存储文件名
mov bx, offset files ;BX -> 文件数组
sub bx, 2 ;为第一次重复指定情况
指导: 使用空行来从周围代码中分离出一块特定的代码。如果你需要更强烈地从代码中分开两块代码那么使用有美术价值的星号行或虚线行(无论如何,不要过分使用)。
如果两段汇编语句序列大概等于两句高级语言,在这两段序列中放一个空行通常都是好主意。这样能帮助读者在脑中理解这两段代码。当然,这很容易失去控制和插入太多的空行在一个程序里。所以在这里使用一些公共常识:
指导: 如果两段汇编语句序列大概等于高级语言里的两句,那么使用一个空行来分离这两段汇编序列(假定序列真的很短)。
在任何语言(不只是汇编语言)里都有一个公共问题,即一个注释行于一行或两行代码相邻。像这样的程序很难阅读因为这很难断定代码的结束处和注释的开始处(反之亦然)。当注释包含简单的代码时更是如此。这通常都很难断定你看到的是代码还是注释;因此得出下面的强制性规则: 强制性规则:始终在代码和注释之间放置至少一个空行(假定,理所当然,注释仅被它自己设置为一行;也就是说,这里没有行尾注释(注13))。 6.0注释
来自于汇编语言里的注释通常有两种:行尾注释和独立注释(注14)。从它们的名字可以猜想到,行尾注释始终出现在一个源语句的后面而独立注释则它们自己占用一行(注15)。这两种注释方式都有各自的目的,该节将会展开它们的使用和谈论一个有良好注释程序的属性。
6.1什么是一个坏注释?
令人惊奇的是许多程序员声称它们的代码都具备良好注释。当你数过注释分隔符之间的字符时,它们通常都有一点,不管怎样,考虑一下下面的注释:
mov ax, 0 ;将AX设为0
很坦白地说,该注释比没有注释更坏。它并不告诉读者该指令本身的任何事情不告诉并它自己要求读者要花费他或她的宝贵时间来指出这个注释是毫无价值的。如果那个人没有说这条指令是将AX设为0的话,他们在读一个汇编程序时就不会有琐事了。
这引出了该节的第一个原则:
指导: 为你的源代码选择一类预计的读者并为这些读者写注释。对于汇编源代码来说,你通常能假定目标读者是已学习过相关汇编语言的那些。
别在你的代码中解释一个汇编语言的动作除非该指令做了一些不明显的事情(和许多时候如果代码不能明显表现出做什么时应该考虑改变代码序列)。作为替换,解释那条指令是怎样帮助解决附近的问题的。下面对前面的指令的注释就更好:
mov ax, 0 ;AX是求和结果,将它初始化。
注意注释并没有说“将它初始化为0”尽管这样说没有本质上的区别,短语“将它初始化”不管你赋给AX什么值都保留了正确性。无论你什么时候修改了指令都无需更改与它关联的注释,这使得处理代码(和注释)更为容易。
指导: 以这种方式写你的注释:对于指令的小更改并不需要改变相应的注释。
注13:看涉及注释的下一节来了解更多的信息。
注14:当提到独立注释时该文档会简单地使用术语“注释”。
译注: 行尾注释的意思是在语句后面写注释,这时语句和注释都在同一行。而独立注释则是在代码之前独立一块的注释,这或许是一行,或许是N行。
注15:因为标号、助记符和操作数域都是可选的,所以它自己独立有一行注释是合法的。 注意:尽管一个无价值的注释是严重的(当然,比没注释更坏),但最坏的注释是一个程序有此注释后是错误的。考虑下面的语句:
mov ax, 1 ;将AX设为0
令人惊奇的是程序将AX设为0而当它并没有明显这么做的时候看这份代码的人将会花费多长时间来理解。比起代码人们会始终更相信于注释。如果在注释和代码间存在多义性,他们会假设代码是骗人的而注释是正确的。只有在耗尽所有的可能选择后一般人才可能承认注释肯定是错的。
强制性规则: 在你的程序中永远不允许出现错误注释。
这是另一个在你的代码中不允许出现像“将AX设为0”这样有二义性注释的理由。在你修改程序的时候,这些注释很可能会在你改变代码和不能同步更新注释的时候发生错误。无论如何,即使是一些没有二义性的注释都能在你改变代码的时候发生错误。因此,始终遵循下列的规则:
强制性规则: 在改变代码后始终更新被代码直接影响到的所有注释。
你肯定听过这句话“确认你注释你的代码就像其它人写给你那样;否则6个月内你也会希望你有这样做”。这句话包含两个概念,第一,不要整天认为你最终都会理解你当前的代码。当工作在程序中的一段代码时你可能会投入大量的思考和研究来了解你的进度,6个月下来,无论如何,你会忘记很多以前了解的内容而这时注释则对你快速回顾大有帮助。第二点则牵涉到该代码也会被其他人阅读和改写。当你必须读其他人的代码的时候,其他人也要阅读你的代码。如果你写注释的时候你也会期望别人也为你写点注释,你的注释在为他们工作的很好的时候你也会有很好的机遇。
规则: 永远不要使用种族歧视、性别歧视、含糊不清、或其它有政治意图的错误语言在你的注释中。毕竟这样的语言在你的注释中将来很可能会回到你的头上,因此,像这样的语言会帮助别人更好地理解程序是值得怀疑的。
比起描述一个好注释来更容易给出一个坏注释的例子。下面列出一些可能是你放在程序中最坏的注释(从最坏到恰到好处):
• 你能放到程序中最坏的注释是一个有错的注释,考虑下面的汇编语句:
mov ax, 10; { 将AX设为11 }
令人惊奇的是许多程序员会自然而然地假定注释是正确的并当代码明显地将变量“A”设为10时尝试理解这个代码的处理是将变量设为11。 • 你能放到程序中第二坏的注释是一个说明语句做什么的注释。典型的例子就像“mov ax, 10; { Set ‘A’ to 10 }”。不像之前的例子,这个注释是正确的。但是它仍然比没有注释更坏,因为它是冗余的并强迫读者花费额外的时间来读代码(阅读时间是直接与阅读难度成比例的)。这也使得只是对代码稍微改变一下(例如,"mov ax, 9")就需要更改注释,这样就更难维护它了。
• 在程序中第三坏的注释是一个不切题的注释。例如,说一个笑话,也许看上去漂亮,似乎它对一个程序的可读性做了点改善;实际上,它分散了注意力。
• 第四坏的注释就是没有注释。
• 第五坏的注释是一个已经废弃的或过时(尽管没有错)的注释。例如,一个文件的开头注释也许是描述一个模块的当前版本和最后在该模块工作的是谁。如果最后一个程序员修改了文件但没有更新注释,现在的注释就是过时的。
6.2什么是一个好注释?
Steve McConnell为高质量代码提供了一份长长的建议列表,这些建议包括:
• 使用不中断或阻止改变的注释风格。基本上,他是说选择一种当不需要注释时人们不需要做太多工作的注释风格。他给的一个用星号包围一块注释的例子变得难于处理。当现在文本编辑器能为你自动地“勾画”注释时这就是一个不良的例子了。尽管如此,基本思想还是值得听取的。
• 注释与你同步在前进。如果你直到最后一分钟才推迟注释的话,那么看起来就好像在软件开发进度中有另一个任务接着而来并要求处理,而这更像是阻止你完成注释任务并希望有新的最后期限。
• 避免自纵容注释。并且,你应该避免性别歧视、粗俗的、或其它含有侮辱性的言语在你的注释中。始终谨记,有人会最终认得你的代码。
• 避免将注释与它们所解释的语句放在同一行。当拥有的空间很小时这样的注释会非常难管理。McConnell认为在变量声明中使用行尾注释是好的。尽管这可能没问题,但许多变量声明可能需要相当多的说明以致于不能简单地使用行尾注释了。该规则的一个例外是涉及到一个故障追踪进入故障数据库的“维护记录”注释是没问题的(注意:CodeWright文本编辑器为此提供了一个很好的解决方案-有更新外部文件的按钮)。当然,行尾注释在汇编语言里跟McConnell所说的在高级语言里或多或少更有帮助。但基本思想是值得听取的。
• 编写解释一块语句的注释好过编写解释单独语句的注释。遍布单行注释倾向于解释那条语句做什么而更胜于解释程序做什么。
• 重点段落注释为什么这么做更胜于解释怎么做。代码应该解释程序做什么和为什么选择这样做更胜于解释每条语句做什么。
• 使用注释来为读者准备接下来做什么。其他阅读注释的人将会有个接下来代码做什么而无需真正查看代码的好思想。注意该规则同样建议那份注释应该始终放在代码之前。
• 构造每个注释计数(译注:反语)。如果读者浪费时间来阅读有小数值的注释,在这期间程序会更难读。
• 奇怪的文档和棘手的代码。当然,最好的解决方法是没有任何棘手的代码。事实上,你不可能完全达到这个目标。当你需要保留一些棘手的代码时,确保你为此提供了足够的文档。
• 避免使用缩略语。当在程序中出现为一个参数缩写标识符时,不要在注释中缩写。
• 保持注释与它们所解释的代码紧闭。一个程序单元的开头应该给定它的名称,描述参数,并为程序提供一个简短的描述。这应该不会详细说明模块本身的操作。内部的注释才是做这些事的。
• 注释应该解释一个函数中的参数,确定这些参数:输入、输出、或输入/输出参数。
• 注释应该描述一个程序的局限性、假设和任何负面影响。
规则: 所有使用简明方式解释周边代码动作的注释都会是高质量的注释。 6.3行尾注释 vs. 独立注释
指导: 无论什么时候出现单独一行注释,始终在第一列放置一个分号。如果是适当的或更富美感的你可以缩进文本。
指导: 注释相邻的行之间应该没有任何散布的空白行。一个空白注释行应该,至少,包含一个分号在第一列。
上面的指导认为你的代码看起来应该是这样的:
; This is a comment with a blank line between it and the next comment.
;
; This is another line with a comment on it.
而不是这样:
; This is a comment with a blank line between it and the next comment.
; This is another line with a comment on it.
出现在两条语句之间的分号认为当你移除分号时不会出现连续性。如果两块注释真的用空白符来分隔它们是适当的,你应该考虑使用大量的空行来分割它们以便消除它们之间任何可能的关联。
当描述代码接下来立即要做的动作时使用独立注释是极好的。那要行尾注释有什么用?行尾注释能说明一系列指令是如何实现之前独立注释所解释的算法。考虑下面的代码:
; 使用算法计算转置距阵:
;
; for i := 0 to 3 do
; for j := 0 to 3 do
; swap( a[i][j], b[j][i] );
;译注:上面的一块即为独立注释
forlp i, 0, 3
forlp j, 0, 3
mov bx, i ;Compute address of a[i][j] using
shl bx, 2 ; row major ordering (i*4 + j)*2.
add bx, j ; 译注:这就是行尾注释。
add bx, bx
lea bx, a[bx]
push bx ;Push address of a[i][j] onto stack.
mov bx, j ;Compute address of b[j][i] using
shl bx, 2 ;row major ordering (j*4 + i)*2.
add bx, i
add bx, bx
lea bx, b[bx]
push bx ;Push address of b[j][i] onto stack.
call swap ;Swap a[i][j] with b[j][i].
next
next
注意块注释在这顺序解释之前,在高级语言术语里描述代码做什么。行尾注释则解释指令序列是如何实现这个常规算法的。注意,无论如何,行尾注释不要解释每条语句在做什么(至少在机器级里)。宁愿说明“add bx, bx”是将BX乘于2,该代码假定读者能理解它们(任何适当的汇编程序员都知道这个)。再次说明,时刻在你的脑中为你的读者写你的注释。
6.4未定义代码
程序员通常都会遇到编写了一节代码(不完整)完成一些任务但需要更多工作来完成该功能,为了使它更健壮,或在代码中移除一些已知的故障。这样的程序员都面临一个公共问题,如在代码中插入“这里需要更多的工作”、“前面未完善”等等注释。这些注释的问题在于他们经常忘记。问题直到那段失败的的代码所关联的注释被找到才修正过来。
理论上,大家不应该将这样的代码放进程序里。当然,是理论上,除非程序没有任何问题。当在程序中无可避免地找到这样的代码后,在本节有一个处理问题的好策略。
未定义代码有五种常规类型:无功能代码、不完整代码、可疑代码、需要增强的代码、和代码文档。无功能代码可能是一个将来要被实际代码替换的框架或驱动,或者是一些严重到除了一些特别情况外不再有用的代码。这种代码真的很差,幸运的是它的严重性阻止了你忽略它。未必每个人都会在测试发行版之前忘记这样一个构造甚差的代码。
不完整代码是、或许是最大的问题。这种代码通过一些简单测试工作的很好,但仍然包含一些需要纠正的严重错误,况且,这些错误是已知。软件经常会包含大量的未知缺陷;若因为一个程序员忘记了一个缺陷或之后未能找到该缺陷而让这些已知的缺陷依然简单地加载到产品上,这真是一个羞耻。
可疑代码是真正的-被怀疑的代码。程序员未能真正确定问题但可以怀疑有个问题存在。这种代码需要以后评审来确定到底是不是正确。
第四种,需要加强的代码,是最不严重的。例如,为了加快发出一个发行版,程序员或许会选择一个简单的算法而不是一个复杂的、更快的算法。他/她也许会在代码中像这样注释:“在未来的软件版本中这个线性查找应该被替换为一个哈希表查找”。尽管这无需完全纠正一个程序,但也是一个容易知道的问题并会在将来对它进行处理。
第五种,文档型的,一种涉及到软件修改会影响相应文档(用户指南、设计文档等等)的类型。文档部门能在当前代码中搜索这些缺陷并产生文档。
这里准确定义了处理这五种问题的的技巧。发生任何任何一种未定义代码都会被下列的其中一种注释预先处理掉(“_”表示单独一个空格):
;_#defect#severe_;
;_#defect#functional_;
;_#defect#suspect_;
;_#defect#enhancement_;
;_#defect#documentation_;
所有定义使用小写字母和拼写验证都是重要的,以便可以使像grep这样的文本编辑工具来搜索这些注释。很明显,注释的位置必须是紧跟着源代码。 例如:
; #defect#suspect ;
; #defect#enhancement ;
; #defect#documentation ;
注意在汇编语言两边的注释分界符的使用(分号),并不是必须的。
强制性规则: 如果一个模块包含一些因为时间或其它原因不能立即移除的缺陷,程序应该在代码前插入日一个标准注释以便在将来容易找到这个问题。五种标准注释是:;_#defect#severe_;
;_#defect#functional_;
;_#defect#suspect_;
;_#defect#enhancement_;
;_#defect#documentation_;
“_”表示单独一个空格。拼写和位置应该是正确的,以便在源代码树中容易找到这些字符串。
6.5代码中交叉参考到其它文档
在许多实例中一节代码可能会内部与其它文档进行通讯。例如,你可能会在一个程序中引用用户文档或设计文档中的读者。该文档提议一种标准方法来做这件事,即相对容易地在源代码中定位这些交叉参考。该技术像是用来做缺陷报告,除了从这样的注释中获取:
; text #link#location text ;
“Text”是可选的并表示任意文本(尽管这是预先隐藏html命令来提供指定文档的超链接)。“Location”表示文档和用于搜索的相关信息。
例如:
; #link#User’s Guide Section 3.1 ;
; #link#Program Design Document, Page 5 ;
; #link#Funcs.pas module, "xyz" function ;
; <A HREF="DesignDoc.html#xyzfunc"> #link#xyzfunc </a> ;
指导: 如果一个模块包含一些交叉到其它文档的参考,它们应该是像“; text #link#location text ;”一样的注释,以便提供对其它文档的参考。在注释里的“Text”表示一些可选文本(特意为html标记保留),“Location”是一些关联当前程序中代码的描述文档(和那个文档的位置)的说明。 7.0名称、指令、操作数和操作
尽管程序中像好注释、语句间适当的分隔、和好的模块化能帮助一个程序更易读;但归根结底,一个程序员必须在程序中阅读这些指令来了解程序在做什么。因此,不要低估使你的程序尽可能易读的重要性。该节就详细讨论这个问题。
7.1名称
根据IBM的调查,在程序中使用高质量标识符比使用其它任何单一特性更具可读性,包括高质量注释。标识符的质量能连通或中断你的程序;具备高质量标识符的程序可以非常易读,拥有低品质标识符的程序将会非常难读。这里只有很少的“技巧”来开发高质量标识符;许多规则仅仅是老式的常识感觉罢了。不幸地,程序员们(尤其是C/C++程序员)开发了许多怪异的名称规范而忽略了公共规范。最大的障碍就是许多程序员在不情愿抛弃已存在的约定下必须学习怎样创建新的好名称。可是他们只是在防止被调查为什么要坚持(现有的)有害约定,如“因为那是我一直使用的方式并且其他人也在使用”。
上述在IBM的研究人员开发了几个拥有下列属性的程序:
• 坏注释, 坏名称
• 坏注释, 好名称
• 好注释, 坏名称
• 好注释, 好名称
应该很明显,拥有坏注释和坏名称的程序应该是最难读的;同样,那些拥有好注释和好名称的程序是最易读的。但令人惊奇的是有关另外两种情况的结果。许多人会认为在一个程序中好注释比好名称更重要。不仅仅是IBM发现这是错误的,人们也发现他们真的错了。
这表示,好名称在一个程序中甚至比好注释更重要。这并不是说注释不重要,它们都是非常重要的;无论如何,如果你花费时间去写好注释却为你的程序选择了一堆差名称是值得注意的。尽管你有在程序中做注释但仍然破坏了程序的可读性。快速阅读下列的代码:
mov ax, SignedValue
cwd
add ax, -1
rcl dx, 1
mov AbsoluteValue, dx 问题: 该代码计算了什么并在AbsoluteValue变量里保存了什么?
• SignedValue的负号扩展.
• 对SignedValue取反.
• SignedValue的绝对值.
• 一个指出结果为正或负的布尔值.
• 正负号(SignedValue) (-1, 0, +1 if neg, zero, pos).
• 高位值(SignedValue)
• 低位值(SignedValue)
SignedValue的绝对值是最明显的答案。但这同样是错的,正确的答案是求正负号:
mov ax, SignedValue ;获取检验值
cwd ;如果是负数DX = FFFF, 其它情况=0000
add ax, 0ffffh ;如果ax为0 Carry=0, 其它情况=1
rcl dx, 1 ;如果ax为负数DX = FFFF,ax为0则=0,
mov Signum, dx ;ax>0则为1.
我同意,这仍然是一份棘手的代码(注16),即使没有注释你也能大概知道代码序列在做什么但却不知道代码是怎样做的:
mov ax, SignedValue
cwd
add ax, 0ffffh
rcl dx, 1
mov Signum, dx
从名称的基础上你可能仅仅知道代码是计算正负号的函数。这就是之前谈到的“80%的代码能理解”。注意你不会因使人迷惑的名称而对代码产生错觉。考虑下面拥有使人误会名称的代码:
mov ax, x
cwd
add ax, 0ffffh
rcl dx, 1
mov y, dx 注16:这也许是最坏的,你应该查看正负号函数的“超级优化器”输出的是什么。这份代码即短又难以理解。 这只是一个很简单的例子。现在想象一下一个大程序有相当多的名称,与在一个程序中不断增长的名称。这会导致难于保持对它们的跟踪。如果这些名称本身并没有提供足够的暗示来表达名称所代表的含义,理解一个程序将会变得更为困难。
强制性规则: 出现在汇编程序里的所有标识符都必须是能清晰表达含义和用途的描述性名称。
当标号(即标识符)是跳转和call指令的目的地时,一个典型的汇编程序会有一大堆标识符。因此,它就会引诱人们开始使用像“label1、label2、label3、...”这样的名称。消除这种诱惑!你在代码中跳转到一些地方时始终是一个理由。尝试描述跳转原因并为你的标号使用描述性的名称。
规则: 不要在你的程序中使用像“Lbl0、Lbl1、Lbl2...”这样的名称。
7.1.1命名约定
名称约定是在众多计算机科学研究领域中遥远的一块(程序编排是另一个基础领域)。一个对象名在程序语言中的主要目的是用来描述那个对象的使用和/或对象所包含的内容。第二个考虑因素是描述对象的类型。程序员们使用不同的技巧来处理这些对象。不幸地,这里有太多的“约定”了,它会要求太多并期望任何一个程序员遵循几个不同的标准。因此,这些标准会尽可能地交叉出现在所有的语言中。
一大堆的程序员只知道一种语言??英语。一些程序员的英语是第二语言并可能会在他们的语言里不熟悉一些公共的非英语短语(例如,rendezvous(译注:作者可能还不知道我有金山词霸^_^))。正规英语是许多程序员的公共英语,所有的标识符应该使用容易辨认的英语单词或短语。
规则: 表示单词或短语的所有标识符必须是英语单词或短语。(译注:)
7.1.2字母大小写考虑
一个同时含有大小写字符的标识符在你用一个区分大小或不区分大小写的编译器编译它时会有不同的工作。事实上,这意味着所有的标识符都必须使用同样的方式来拼写正确(包括大小写)而且不存在只是大小写不同的标识符。例如,如果你在Pascal(一个大小写不敏感的语言)里声明了一个“ProfitsThisYear”标识符,你可以合法地使用“profitsThisYear”和“PROFITSTHISYEAR”来访问这个变量。无论如何,当一个大小写敏感的语言会将这三个变量处理为不同的标识符时这不是一个大小写无关的使用。相反,像在C/C++这些大小写敏感的语言里,这可能会在程序里创建“PROFITS”和“profits” 两个不同的标识符。当在一个大小写不敏感的语言(例如Pascal)里试图连接它们时连接器会认为这两个标识符同名而产生一个错误,这时这些标识符就是大小写无关的。 强制性规则: 所有的标识符必须是“中性大小写”
不同的程序员(特别是在不同的语言里)会使用字母大小写来表示不同的对象。例如,一个公共的C/C++编码习惯是使用所有大写字母来表是一个常数、宏、或类型定义并使用所有小写字母表示变量名或保留字。早前的程序员使用一个开头大写字母的标识符来表示一个变量。其它相似的编码习惯也存在。不幸地,字母大小写的使用有太多的习俗,它们几乎都是毫无价值的,因此引出下列规则:
规则: 你决不应该使用字母大小写来表示一个类型、类定义、或其它任何与程序关联的标识符的属性。
上面的规则存在一些明显的例外情况,本文会在稍后讨论这些例外。字母大小写在标识符里有个非常有用的功能-对于分离一个多单词的标识符时非常有用;能立即分辨出那个对象。
为了编写出一个易读的标识符经常需要一个多单词的短语。自然语言典型地使用空格来分离各个单词;无论如何,我们不能在标识符里使用该技术。如果你不为单独提取各单词做些事的话编写多单词标识符会不幸地导致它们不可能读。解决该问题有一个好习惯。这个标准习惯就是在标识符里为每一个单词的首字母大写(译注:即我们通常所说的匈牙利表示法,如:SetWindowLong)。
规则: 在一个多单词的标识符里将内部单词的首字母大写。
注意上面的规则并没有特别指定一个标识符的首字母要大写或小写。根据其它规则来调节大小写,你可以为第一个符号选择使用大写或小写,尽管你应该在你的程序中自始自终保持一致性。
小写的字符比大写更易读。完全用大写完成的标识符通常都要用两倍的时间来辨认它并因此削弱了程序的可读性。是的,所有字符大写会使得一个标识符突出。很少需要在真实程序里使用强调。是的,公共的C/C++编码惯例规定了标识符所有字符大写的使用。忘记它(译注:只是叫你在汇编语言里忘记它)。它们不仅使得你的程序难于阅读,同时也违反上面的第一个规则。
规则: 避免在一个标识符里全部使用大写字符 7.1.3缩略语
一个标识符的主要目的是为了描述它的使用或它所关联的值。为一个对象创建一个标识符的最好方法是用英语来描述那个对象并据此创建一个变量名。变量名应该是有意义的、简洁的,并不会对一个流利使用英语的普遍程序员造成二义性。避免短名称。一些研究发现,一个使用平均长度为10-20个字符的程序通常比使用更短或更长标识符的程序更容易调试。
尽可能避免缩略语。对你来说好像是完美地使用了合适的缩略语,但可能会完全把其他人搞混。考虑下面真实出现在商业软件中的变量名:
NoEmployees, NoAccounts, pend
“NoEmployees”和“NoAccounts”变量看起来好像是表示雇员和帐户存在或不存在的布尔型变量。事实上,这个程序员是使用(在现实生活里非常合理)“number”的缩略语来表示雇员和帐户的数目。“pend”名称表示一个过程的终止而不是挂起的操作。
程序员通常在两种情况下使用缩略语:他们是不良的打字员而且他们想减少输入工作,或仅仅是为一个对象起个描述性好的名称太长了。第二种情况,尤其是在不留心的情况下,可能会不经意地使用了一个缩略语。
指导: 在你的程序中避免一切标识符缩略语。当需要的时候,使用标准化的缩略语或叫其他人检查你的缩略语。不管什么时候在你的程序中使用缩略语,你都应该在名称定义附近的注释里创建一个“数据字典”来提供缩略语的全名。
你创建的变量名应该是能读出来的。“NumFiles”标识符就比“NmFls”要好。第一个能被拼读出来,第二个则通常要吃力些。避免同音异议词和除了一些音节以外的长名称。如果你为你的标识符选择了一个好名字,阅读一个程序就好像阅读列在电话本里的人名一样不会被搞混。
规则: 所有的标识符应该是可拼读的(在英语里)而无需费力地一个个字符都读出来。 7.1.4标志符内各成分的位置
当检查一份程序清单时,许多程序员仅仅会读一个标识符的前几个字符。这很重要,因此,将最重要的信息(那些定义和唯一标识该标识符)放在标识符的前几个字符里。所以,你应该避免创建一些用相同短语或字符序列开始的标识符,因为这会在程序员阅读清单的时候打断程序员智力地处理在标识符里的附加字符。当因此而减慢阅读速度的时候,它就会使程序更难阅读。 指导: 努力使得大多数标识符的前几个字符就唯一。这将使得程序更易读。
推论: 永远不要使用数字下标来区分两个名称。
许多C/C++程序员,尤其是微软的Windows程序员,都采用了一个正式的“匈牙利表示法”命名约定来引用Steve McConnell在Code Complete所说的:“‘匈牙利’术语涉及到两个事实,一个是遵循该约定的名称看起来像外语单词,而创建该约定的人事实上也是外国人,Charles Simonyi,是匈牙利表示法的原创者”。第一个规则涉及到所有标识符都是英语名称。我们真的要创建“人工外语”标识符吗?匈牙利表示法事实上违反了另外一个规则:使用匈牙利表示法的名称通常都有非常普遍的前缀,因此使得它们难于阅读。
匈牙利表示法也有一些小优点,但是缺点远远超过了其优点。下面从Code Complete摘来的和其它一些源文件讲述了匈牙利表示法的缺点:
• 匈牙利表示法通常使用基本机器类型术语来定义对象而不是使用抽象数据类型。
• 匈牙利表示法使用表示法组合一些意义。高级语言的一个主要目的是抽象表达。例如,如果你用整形定义了一个变量,你不能仅考改变变量的名称来将它变为实型。
• 匈牙利表示法鼓励懒惰,不提供变量名信息。的确,在Windows程序中很容易找到这些只含有类型前缀而不追加描述性名称的变量名(译注,如hwnd、hDc、wParam)。
• 匈牙利表示法加了一些类型信息的前缀名称,因此难于用编程查找这些名称的描述性部分
规则: 避免使用匈牙利表示法和其它为标识符追加低级类型信息的正式命名约定。
尽管为一个标识符追加机器类型信息是一个坏思想,一个经慎重考虑后的名称能成功地关联一些高级类型信息,尤其是名称暗示了数据类型或以一个前缀出现类型信息。例如,像“PencilCount”和“BytesAvailable”这样的名称暗示了整形值。同样,像“IsReady”和“Busy”表明是布尔值。“KeyCode”和“MiddleInitial”认为是字符型变量。一个像“StopWatchTime”的名称可能表示为一个实型变量。同样,“CustomerName”可能表示为一个字符串变量。不幸地,为一个对象选择一个即包含内容又包含类型的名称通常要花费大量的时间。当一个对象是一些抽象数据类型的实例(或定义)时更是如此。在这样的实例里,一些附加文本能改良标识符。匈牙利表示法是该方法的一个自然尝试,不幸地,由于各种各样的理由而失败了。
一个较好的解决方法是使用一个后缀短语来指出一个标识符的数据类型或类名。一个公共的UNIX/C约定,例如,应用一个“_t”后缀来指出一个类型名(例如,size_t、key_t等等)。该约定因几个理由而比匈牙利表示法成功:(1)“类型短语”是一个后缀而不妨碍名称的阅读,(2)具体的约定指出一个对象的类别(常数、变量、类型、函数等等)而不是指出低级数据类型,(3)如果它的类别改变了,确实能感觉到标识符的变化。
指导: 如果你想将标识符区分为常数、类型定义、和变量名,分别使用“_c”、“_t”、和“_v”后缀。
规则: 各个类别后缀不应该仅仅是区分两个标识符的部件。
我们能为变量应用这个后缀思想并避免因疏忽而出毛病吗?有时。考虑一个Visual Basic或Delphi表单中与相应的按钮通讯的高级数据类型“button”。一个像“CancelButton”一样的变量名很容易了解到。同样,出现在一个表单上的标签可能会使用像“ETWWLabel”和“EditPageLabel”这样的名称。注意这些后缀仍然遭受当你需要改变变量名时的影响。无论如何,高级类型里的改变要远小于在低级类型里的改变,所以,这应该不是当前的大问题。 7.1.5要避免的名称
避免在标识符里使用一些容易弄错为其它符号的符号。这包括一组{“1” (数字1), “I”(大写 “I”)和 “l” (小写 “L”)}、 {“0” (数字0) 和 “O” (大写“O”)}、 {“2” (数字2) 和 “Z” (大写 “Z”)}、{“5” (数字5) 和 “S” (大写 “S”)}、 (“6” (数字6) 和 “G” (大写 “G”)}。
指导: 避免在标识符里使用一些容易弄错为其它符号的符号(看上面的列表)
避免使人误解的缩略语和名称。例如,FALSE不应该是一个代表“失败于一个合理的软件工程方法”的标识符。同样,你不应该在计算一个程序有效空内存时将它填满到“Profits”变量。
规则: 避免使人误解的缩略语和名称。
你应该避免使用相似意义的名称。例如,如果你有“InputLine”和“InputLn”两个不同目的变量,当阅读代码的时候必定会被这两个变量搞混。如果你在互换两个对象的名称后仍然能意识到程序混淆,你就应该重新为这些标识符命名。注意最好是名称意义不要太相像,“InputLine”和“InputBuffer”明显不同但你仍然很容易在程序里混淆它们。
规则: 不要在你程序中的为不同对象使用相似意义的名称。 沿着相似的脉路,你应该避免使用两个或多个不同意义但名称类似的变量。例如,如果你在写一个教师分级程序,你可能想用“NumStudents”来表示在教室里的学生数目并连同“StudentNum”变量来表示单独一个学生的ID号。“NumStudents”和“StundentNum”就太相似了。
规则: 不要为有不同意义名称起相似的名字。
避免使用当阅读时听起来相似的名称,尤其是超出上下文环境的。这可能包括像“hard”和“heart”、“Knew”和“new”等等。回想上面的缩略语一节,你应该将你的程序描述地像列在电话上一样。听起来相似的名称会使得这种描述变得困难。
指导: 避免在标识符里使用同音异义词。
避免在名称里拼错单词和避免通常会拼错的名称。许多程序员都是声名狼籍的不良拼读者(看看我们代码中的一些注释)。正确地拼读是很难的,要记得如何错误地拼读一个标识符甚至更难。同样,如果一个单词经常被拼错,要想程序员将它正确拼读的话可能疑问会更多。
指导: 避免在标识符里使用经常被拼错的单词和名称。
如果你在代码中重新定义一些库程序的名称,其它程序会被你命名的该库程序搞混。当处理标准库程序和API时更是如此。
强制性规则: 不要在你的程序中重用已存在于标准库程序中的名称,除非你使用一个有相似的语义的名称明确地替换那个程序(也就是,不要为一个不同目的的名称重用相似的名称)。 7.2指令、伪指令和伪操作码
你选择的汇编语言序列,也就是指令本身,和你所选择的伪指令和伪操作都对你程序的可读性有着大影响。下面的小节就讨论这些问题。
7.2.1选择最好的指令序列
跟任何语言一样,你可以使用各种不同的指令序列来解决一个给定的问题。在一个连续的例子里,(再次)考虑下面的代码序列: mov ax, SignedValue ;获取检验值
cwd ;如果是负数DX = FFFF, 其它情况=0000
add ax, 0ffffh ;如果ax为0 Carry=0, 其它情况=1
rcl dx, 1 ;如果ax为负数DX = FFFF,ax为0则=0,
mov Signum, dx ;ax>0则为1.
现在考虑下面同样能实现计算正负号的函数:
mov ax, SignedValue ;获取检验值
cmp ax, 0 ;检查符号
je GotSignum ;如果为0就完成计算
mov ax, 1 ;假定它为正数
jns GotSignum ;如果为正数则转移
neg ax ;如果为负数则返回-1
GotSignum: mov Signum, ax
是的,第二个版本更长和更慢。但无论如何,一个普通人都能阅读该指令序列并明白它在做什么;因此第二个版本比第一个版本更易读。那份序列最好?除非速度或空间是关键因此并你能表明该程序只在关键执行路径上,否则第二个版本明显更好。这是一个在时间和空间上的都不易处理的汇编代码;无论如何,很少需要在你整个程序中加入这样技巧。
所以,当有许多方式可以完成同一个任务时该如何选择一个适当的指令序列?最好的方式是确保你有一个选择。尽管有许多不同的方法可以完成一个操作,一些人会因与第一次想到的指令不同而被其它指令序列搞糊涂了。不幸地,“最好”的指令序列就是许多人第一次想到的指令序列(注17)。为了做出一个选择,你应该使指令序列可以选择。也就是说,如果在你的代码中涉及可读性问题的话你应该至少为一个给定的操作创建两个不同的代码序列,一旦你至少有两个版本,你就能在你需要时对它们做出选择。当它成为不实际的“将你的程序写两次”以至于你要为程序中的每份指令序列做出一个选择,你可以为特别麻烦的代码序列使用该技术。
指导: 为应付特别难懂的代码段,尝试用不同的方法来解决问题。然后结合你的程序选择一个最容易理解的解决方案。 注17:不管你怎样确定“最好”的代码序列,这都是正确的。 上面的建议有一个问题,即你会经常封闭在像“这份代码并不难懂,我不需要担心它”的决策中。找其他人来检查你的代码并指出他们找到的难懂的地方通常是一个好方法(注18)。
指导: 你会在检查确定为了更容易理解而需要在程序中重写的这些代码段中获得好处。 7.2.2控制结构
Ralph Griswold(注19)曾经在C、Pascal、Icon中(粗略地)说过“C使得它容易编写但难于阅读(注20),Pascal使得它难于编写难于阅读,Icon使得它容易编写容易阅读”。汇编语言也可以概括为:“汇编语言使得它难于编写易读的程序但容易编写难读的程序”。这要相当多的训练来编写易读的汇编程序;但它能做到。伤心地,你今天找到的许多汇编代码都写的很粗劣。的确,这种情况就是出现该文档的最大理由。你曾经遇过像注释和命名约定的问题。像程序控制流和数据结构设计这样的问题一直都是程序可读性的最大影响。自从许多汇编语言缺乏对控制流的构造后,这里一直是未受训练的程序员展示他们粗劣代码的区域。在Internet的公共代码域里或微软为那些问题而给出的简单汇编代码(注21)中不难看到这些拙劣的代码。
幸运地,在经过一个小小的训练之后就可能写出易读的汇编程序。如果设计控制结构是你程序可读性的一个大影响。最好的方法可以概括为两个单词:avoid spaghetti(译注:避免像意大利面条)。
意大利面条式的代码表示在代码序列中有一大堆交叉缠绕的分支语句在里面。考虑下面的例子: 注18:当然,如果程序是一个班分配的,那你应该在向你的同学展示你的工作之前检查有欺骗性的指令。
注19:SNOBOL4和Icon编程语言的设计者。
注20:注意这并不能推断出很难编写出易读的C程序。只表示如果这样随便的话很容易编写出一个几乎不可能明白的程序。
注21:OK,这是一个肤浅的猜测,事实上,这个星球上的很多汇编代码都写的很拙劣。 jmp L1
L1: mov ax, 0
jmp L2
L3: mov ax, 1
jmp L2
L4: mov ax, -1
jmp L2
L0: mov ax, x
cmp ax, 0
je L1
jns L3
jmp L4
L2: mov y, ax
顺便说一下,这份代码,就是我们的好朋友-计算正负号函数。这要花费几分钟来理解该代码,因为当你人工追踪代码的时候你会发现自己花费了很多时间在跳转流中查看计算有效结果的代码。当前的代码是一个非常极端的例子,但它也可以相当短。一个长代码序列只会在放置少数分支的地方被迷惑。
将代码称为意大利面条是因为它的流程类似于一团混乱的面条。也就是说,如果我们将程序里的控制路径设想为一份面条,意大利面条代码就会包含许多缠绕在一起的分支在里面并脱离在程序的不同节里。不用说,大多数意大利面条式的程序都非常难懂,通常包含许多bugs,而且效率通常很差(别忘了在现代处理器里分支处理一直是最慢的执行指令)。
那么我们应该怎样解决这个问题呢?在汇编代码中采用结构化编程技术很容易在现实中实现。当然,80x86汇编语言并不提供if..then..else..endif, while..endwhile, repeat..until和其它类似的语句(事实上,MASM6.x可以做到,但我们在这里忽略这个事实),但我们能毫无疑虑地模范它们。考虑下面的高级语言代码:
if(expression) then
<< statements to execute if expression is true >>
else
<< statements to execute if expression is false >>
endif
几乎任何高级语言程序都能理解这种类型的语句要干什么。汇编程序员应该抓住这个知识优势来尝试在汇编语言里重新将代码组织成相同的形态。明确地,汇编语言版本应该看起来像下面一样: << Assembly code to compute value of expression >>
JNxx ElsePart ;xx is the opposite condition we want to check.
<< Assembly code corresponding to the then portion >>
jmp AroundElsePart
ElsePart:
<< Assembly code corresponding to the else portion >>
AroundElsePart:
用一个具体的例子来考虑下面的代码:
if ( x=y ) then
write( ’x = y’ );
else
write( ’x <> y’ );
endif;
; 对应的汇编代码:
mov ax, x
cmp ax, y
jne ElsePart
print "x=y",nl
jmp IfDone
ElsePart: print "x<>y",nl
IfDone:
当它看起来明显是组织一个if..then.else..endif语句的方法时,就会很奇怪为什么这么多人很自然地将程序中的esle部分像下面那样摆放:
mov ax, x
cmp ax, y
jne ElsePart
print "x=y",nl
IfDone:
.
.
.
ElsePart: print "x<>y",nl
jmp IfDone 该代码的组织会使得程序更难跟踪。很多程序员都有高级语言的背景并不理会当前的规定,他们还是工作在高级语言里。如果他们模仿高级语言的控制结构将会使汇编程序更易读(注23)。
因为同样的原因,你应该将你的汇编程序模仿成while循环、repeat..until循环、for循环等等。使得代码类似于高级语言代码(例如,一个while循环应该在循环开始时实际测试一个条件并有一个跳转到循环底部的跳转)。
规则: 尝试使用高级语言的控制结构来设计你的程序。你编写的汇编代码组织应该在物理上类似对应一个高级语言程序组织。
汇编语言为你提供了设计任意控制结构的灵活性。这个灵活性的一个原因是优秀的汇编程序员可以用它来构造出比一个编译器(仅仅工作在高级语言控制结构的编译器)更好的代码。无论如何,时刻记住一个快速的程序并不一定需要在每个序列里包含最紧凑的代码。执行速度几乎与大部分的程序无关。为了速度而牺牲可读性在许多程序里并不是一个大赢家。
指导: 避免在你汇编程序中使用不容易映射为简明的高级语言控制结构的控制流程。当效率要求要这样做时,不正常的控制结构应该只出现在代码中很小的一节里。
7.2.3同意义的指令
MASM指令定义了几个同意义的公共指令。尤其是像“条件设置代码”一样的条件转移指令。例如,JA和JNBE就是彼此同意义的指令。逻辑上,可以在同一个环境中使用其它指令。无论如何,同意义指令的选择对代码的可读性也有影响。为了看看为什么会这样,考虑下面的代码:
if( x <= y ) then
<< true statements>>
else
<< false statements>>
endif 注23:有时候,因为性能原因,当顺序代码比转移代码执行的更快时上面的代码会被调整。如果在if语句里很少会执行ELSE部分,跳转到else部分通常是浪费时间的。但如果你对速度做了优化,你就经常需要牺牲可读性。 ; 汇编代码:
mov ax, x
cmp ax, y
ja ElsePart
<< true code >>
jmp IfDone
ElsePart: << false code >>
IfDone:
当一些人读该程序时,“JA”语句跳过了true部分。不幸地,“JA”指令给了一个假像,我们检查后就会明白如果一种事物大于另一种事物;事实上,我们测试以便查看如果一些条件是小于或等于、或大于。同样的,该代码序列隐藏了一些原来高级算法中的意图。其中一个解决方法是交换代码中的false和true部分:
mov ax, x
cmp ax, y
jbe ThenPart
<< false code >>
jmp IfDone
ThenPart: << true code >>
IfDone:
该代码使用了匹配高级算法test(小于或等于)的条件转移。无论如何,这份代码现在组织成一个非直线流的形态(是一个if..else..then..endif statement)。这比起帮助它的适当跳转来更损坏了可读性。现在考虑下面的解决方法:
mov ax, x
cmp ax, y
jnbe ElsePart
<< true code >>
jmp IfDone
ElsePart: << false code >>
IfDone:
该代码组织为传统的if..then..else..endif流。替换JA的使用来跳过then部分,它使用了JNBE来做这件事。这帮助理解,在一个更易读的流程里,如果它不小于或等于的话代码会穿过小于或等于分支。当(JNBE)指令比JA更容易关联原来的test(<=)时
,这将使得该节的代码更易读一点。 规则: 当因为一些条件失败(例如,你因为条件成功而进入代码)而跳过一些代码时,使用从“JNxx”来的指令来跳过那段代码。例如,为了失败于一段如果一个值小于另一个值的代码时,使用JNL或JNB指令来跳过代码。当然,如果你测试一个负数条件(例如,测试相等)时那么使用从Jx里来的一个指令来跳过代码。
8.0数据类型
之前提到的MASM,许多汇编器对定义和分配复杂数据类型提供非常少的兼容性。通常,你可以分配字节、字、和其它简单的计算机结构体。你也可以在旁边设置一块字节。在被高级语言改善后它们就有能力定义和使用抽象数据类型,而汇编语言则落后很远很远。但是,MASM的到来改变了这一切(注24)。不幸地,许多老汇编程序员为学习像数组、结构体、和其它数据类型这种新的MASM语法而烦恼。同样,许多新汇编程序员也为学习和使用这些简单数据定义而烦恼,因为它们已经被汇编语言征服并希望学习数量最小化。这真是一个羞耻,因为MASM数据定义是对汇编语言的一项最大改进就像使用助记符好过使用机器语言的二进制操作码一样。
注意MASM是一个“高级”汇编器。它做了一些其它芯片的汇编器不能做的事,如对操作数类型检查和如果有错则报告错误等。使用其它机器上的汇编器的人找到这些特性后可能会觉得这些特性有些令人讨厌。无论如何,为了同样的理由,在高级语言里和汇编语言里都是一个有利的则面影响(注25):它们帮助其他人理解你在程序中所做的尝试。这应该不令人感到惊奇,因为,这份风格指南鼓励你在汇编程序里使用这些特性。
8.1使用TYPEDEF来定义新数据类型
MASM只提高了很小数量的基本数据类型。为了特定的应用程序,bytes, sbytes, words, swords, dwords, sdwords,和各种各样的浮点类型格式都是现在普遍使用的数据类型。你可以将这些类型进行组合来够造更多的抽象数据类型。例如,如果你想要一个字符,你通常会定义一个字节变量。如果你想要一个16位整数,你可能会使用sword(或word)来声明。当然,当你遇到像“answer byte ?”这样的变量声明时你理解它们的真正类型就有点难了。我们能在这里有个 character、有个boolean、有个小整形、或其它类型吗?这对于最终的计算机来说无所谓,一个字节就是一个字节,它被解释为一个字符、真假类型、或整形值都是被操作它们的机器指令定义好的。并不是你的方式定义它的。尽管如此,对于阅读你的程序的人来说这些区别也是很重要的(或许他们会检验一个给定的数据类型你提供了正确的指令序列)。MASM的typedef伪指令能帮助我们使得这些区别透明起来。
在这个简单的例子里,typedef伪指令的行为像一个textequ。它让你在你的程序用一个字符串替换另一个。例如,你能使用MASM创建下面的声明:
char typedef byte
integer typedef sword
boolean typedef byte
float typedef real4
IntPtr typedef far ptr integer
注24:好了,MASM并不是第一个,但在MASM出现之前这种技术并不流行。
注25:当然,当有需要的时候MASM有能力让你克服这些行为。因此,“老手”汇编程序员就埋怨说这是毫无根据的愚蠢说法。
当你声明了这些名称后,你就可以定义下面的char、integer、boolean和浮点变量了:
MyChar char ?
I integer ?
Ptr2I IntPtr I
IsPresent boolean ?
ProfitsThisYear float ?
规则: 使用现存的MASM数据类型来创建类型块。为了在你的程序中创建大多数的数据类型,你应该使用typedef伪指令来显式定义名称。使用内置基本类型是真正没有借口的(注26:OK!如果你使用的汇编器不支持typedef的话确实是个好借口。)
8.2创建数组类型
MASM为保留一块存储区域提供了一个有趣的功能-DUP操作符。这个操作不常规(在汇编语言中)因为它的定义是循环的。基本定义是这样的(使用HyGram表示法):
DupOperator = expression ws* ’DUP’ ws* ’(’ ws* operand ws* ’) %%
注意“expression”扩展为一个有效的数值(或数值表达式),“ws*”表示“0或更多的空白字符”和“operand”展开为任何内容。在operand操作域中的合法类型有:一个MASM的 word/dw、byte/db等等。伪指令(注27),一个能够特殊地使用该操作来预留一块内存,如下面那样:
ArrayName integer 16 dup (?) ;声明包含16个word元素的数组
这个声明将在内存中设置16个连续的字空间
DUP操作符的一个有趣事情是operand操作域中任何像byte或word等合法的指示都会出现在括号里,包括附加的DUP表达式。DUP操作符可以简单地称为“将该对象循环复制指定次数”,例如,“16 dup(1,2)”表示“给我16个1和2的值”。如果该操作数域中出现一个byte指示,它将预留32个字节,包含重复的1和2。
因此如果我们应用这个递归技术到底发生了什么?好的,“4 dup ( 3 dup (0))”当读这个递归时表示“给我4份括号里的拷贝”。当转到表达式“3 dup (0)”时则表示“给我3个0”。当原操作说给我4份3个0的操作时,该表达式最终表示为12个0。现在,考虑下面的两个声明:
Array1 integer 4 dup ( 3 dup (0))
Array2 integer 12 dup (0)
两个声明都是在内存中设置12个整数(每个都初始化为0)。对于汇编器来说这些操作是非常相近的;对于80x86它们绝对一样。对读者来说,无论如何,它们明显不同。
当你两个一样的一维整数数组时,使用两种不同的定义方式会使你的程序不相容,并,因此而更难读。
注27:为了简短,这些对象的内容没有出现在这里。
无论如何,我们可以采用这种区别来定义多维数组。上面的第一个例子暗示我们有4个一维数组,而每个数组里又包含3个整数。这相当于流行的行列式数组访问功能。上面的第二个例子则暗示我们有一个包含12个整数的单维数组。
指导: 使用DUP操作符的原始优点来在你的程序中声明多维数组。
8.3在汇编语言里定义结构体
MASM为定义和使用结构体、联合体和记录(注:MASM里的记录相当于C/C++里的位字段,并不等于Pascal里的记录)提供了一个极好的功能;因为一些原因,许多程序员忽略了它们并在他们的代码中手工计算结构体中域的偏移。做这些事并不单单使得代码难读,最终几乎是难于维护的。
规则: 当在程序中应该要一个结构体数据类型时,在程序中声明相应的结构体并使用它。不要手工计算结构体里域的偏移。使用标准的“.记号”结构来访问结构体中的各个域。
当你间接访问(即通过一个指针)结构体的域时会发生一个问题。间接访问通常都会出现一个寄存器(一个近指针)或一个[段/寄存器]对(一个远指针)。当你将一个指针值装入一个寄存器或寄存器对时,程序并不能很容易知道你使用了什么指针。如果你在一节代码中间接访问几次而没有将指针重新装入寄存器时更是如此。一个解决方法是使用textequ来创建一个特殊的符号来适当地扩展。考虑下面的代码:
s struct
a Integer ?
b integer ?
s ends
.
.
.
r s {}
ptr2r dword r
.
.
.
les di, ptr2r
mov ax, es:[di].s.a ;并不表示这是ptr2r!
.
.
.
mov es:[di].b, bx ;真的不表示!
现在考虑下面的:
s struct
a Integer ?
b integer ?
s ends
sPtr typedef far ptr s
.
.
.
q s {}
r sPtr q
r@ textequ <es:[di].s>
.
.
.
les di, ptr2r ;译注:这时装入di的应该是r,而非ptr2r
mov ax, r@.a ;现在使用r就相当清楚
.
.
.
mov r@.b, bx ;同上
注意“@”符号在MASM里是一个合法的标识符,因此“r@”只是另一个符号。按常规来说你应该避免使用像“@”这样的标识符。但它在这里有个很好的目的-它表示我们获取了一个间接指针。当然,当使用上面的textequ时你必须始终确保将指针装进了ES:DI里。如果你使用了几个不同的[段/寄存器]对来访问那个“r”指针指向的数据,这个技巧并不使得代码更易读,因为你将需要好几个等价文本来表示同一个事情。
8.4数据类型和UCR标准库
80x86汇编程序员的UCR标准库(2.0及后面的版本)提供了一组允许你像使用C语言语法那样定义数组和指针的宏。下面的例子示范了这个特性:
var
integer i, j, array[10], array2[10][3], *ptr2Int
char *FirstName, LastName[32]
endvar 这些声明表达了下面的汇编代码:
i integer ?
j integer 25
array integer 10 dup (?)
array2 integer 10 dup ( 3 dup (?))
ptr2Int dword ?
LastName char 32 dup (?)
Name dword LastName
为了同C/C++一样轻松,UCR标准库声明看起来很相似。为了这个原因,当编写汇编代码时使用UCR标准库会是个好主意。
维护统计:
2006-9-4
修正:将 endline comments 翻译为行尾注释,而不是原来的行终止注释。修正部分因打字出现的错误,润饰部分翻译。
[招生]系统0day安全班,企业级设备固件漏洞挖掘,Linux平台漏洞挖掘!