Board logo

标题: [原创] 批处理技术内幕:预处理 [打印本页]

作者: Demon    时间: 2012-8-3 16:15     标题: 批处理技术内幕:预处理

标题: 批处理技术内幕:预处理
作者: Demon
链接: http://demon.tw/reverse/cmd-internal-parser.html
版权: 本博客的所有文章,都遵守“署名-非商业性使用-相同方式共享 2.5 中国大陆”协议条款。

我一直认为,批处理水平的高低,并不决定于你是否熟悉所有命令的用法,是否了解各个命令的开关,毕竟这些都能在帮助文档中找到;而决定于你是否能把批处理中那些乱七八糟的符号搞懂,也就是所谓的“预处理”,准确的说应该是批处理的解析过程。

    @echo off
    set ^&=setlocal enabledelayedexpansion
    set ^^^^^hero=^^^^^&p
    set ^au=^^^au
    set ^^^^^^^^^=障眼法
    %&%
    set ^^^^^se=^^^se!
    echo %^^^^%!%^^hero%!au%^se%

传说这段代码出自英雄的“预处理”教程,这让我想起《C陷阱与缺陷》里有的一段话:

    有一次,一个程序员与我交谈一个问题。他当时正在编写一个独立运行于某微处理机上的C程序。当计算机启动时,硬件将调用首地址为0为位置的子程序。

    为了模拟开机启动时的情形,我们必须设计出一个C语句,以显示调用该子例程。经过一段时间的考虑,我们最后得到的语句如下:

    (*(void(*)())0)();

    像这样的表达式恐怕会令每个C程序员的内心都“不寒而栗”。

不知道你看到上面的批处理代码会不会“不寒而栗”,反正我即便是看完了英雄的预处理教程,小心翼翼如履薄冰地分析出了代码运行的结果,仍然对所谓的“预处理”一知半解,也许是我智商太低的缘故。如果你智商比较高,能够很轻松的对付上面的代码,那么下面的内容就没有必要看了。

我一直很反感“预处理”这个词,但是由于这个错误的概念已经深入人心了,我不得不沿用这种说法。还有一点要说明的是,由于CMD解析批处理脚本的过程比较复杂,我不可能在使用OllyDbg分析的过程一一截图,所以尽量用文字来讲解。

在《批处理技术内幕:Unicode》中我已经提到,CMD在解析批处理时每次会从当前文件指针的位置开始读取0x1FFF(8191)个字节(注意不是字符)到缓冲区(不妨起个名字,称之为AnsiBuf),然后将AnsiBuf以当前代码页转换成Unicode储存在另一个缓冲区(称之为UniBuf)。

事实上,在转换成Unicode之前,CMD还做了两件事:第一,在AnsiBuf中寻找"\n\r"或者"\r\n"的组合,如果找到的话就把它们之后的第一个字节修改为NUL(0×00)。第二,重新设定文件指针的位置,将文件指针指向刚才修改的那个字节的位置,这样下次CMD读取批处理时就从没有处理过的地方开始了。

由于AnsiBuf在某个地方被NUL给截断了,所以在调用MultiByteToWideChar函数时只有NUL之前的内容会被转成Unicode。

举个例子来说,如果将下面的代码以ANSI编码,PC格式换行符(\r\n)的形式保存:

    @echo off
    echo http://demon.tw
    pause

那么CMD在第一次读取时,文件指针的位置是0,AnsiBuf的内容为全部的代码,但是由于存在"\r\n",所以AnsiBuf中"@echo off\r\n"后面的’e'会被修改为NUL,转换成Unicode之后UniBuf的内容为L"@echo off\r\n"(沿用C语言的写法,字符串前的L表示宽字符,即Unicode),文件指针会被重新设定为11(即第二行开头)。在对读入内存的第一行代码做完后续处理之后,CMD会再次打开批处理文件,从当前文件指针位置(别忘记已经被修改为11了)继续上面的过程,然后设定文件指针为33(0×21),然后……一直到处理完整个脚本为止。

如果你以Unix格式换行符(\n)保存上面的代码,那么情况会有所不同,因为不存在"\n\r"或者"\r\n"的组合,所以AnsiBuf中的内容会全部转成Unicode保存在UniBuf,CMD只需要读取一次文件(上面是三次)就能处理完整个脚本!是的,我知道这听起来很让人兴奋,减少IO的次数可以提高代码运行的效率,但是我强烈建议你不要这么做,Windows有Windows自己的规则,Unix有Unix自己的规则,在Windows下用Unix的规则恐怕不是一个很好的主意。如果你不听劝告,那么迟早有一天你会碰到莫名其妙的错误,并且百思不得其解。

OK,让我们回到UniBuf来,在转成Unicode之后,CMD又会把它复制到另一个缓冲区,该缓冲区主要用于词法分析,故称之为LexBuf。复制到LexBuf之后,会再次检查内容中是否存在’\n’或者’0x1A’(文件结束符),如果发现’0x1A’则将它修改为’\n’,如果发现’\n’(注意’0x1A已经改成’\n’了)则将它下一个字符修改为NUL(0×00),并且重新设定文件指针,使其指向文件中’\n’下一个字符的位置,那么下次读取时就能继续上次的位置了。这意味着如果在"\r\n"之前出现了’\n’或者’0x1A’字符,那么只会处理到’\n’或者’0x1A’,之后的代码只能等到下一次才能处理了。

如果说上面的过程都无关紧要,那么从这里开始就是重头戏了。接下来CMD会遍历LexBuf,看看是否存在百分号%,如果发现了%就会继续检测它的下一次字符,如果仍然是%,那么就替换成单个%;如果是星号*,并且开启了拓展(ENABLEEXTENSIONS),那么就替换成所有命令行参数;如果是数字("0123456789"),那么就替换成对应的命令行参数;如果以上都不符合,那么就认为是环境变量,继续寻找下一个%,如果找不到配对的%,那么会直接丢弃掉这个单独的%;如果找到了配对的%,那么会将两个%号之间的内容替换为对应的环境变量,如果环境变量不存在,则替换为空字符串。

也就是说,在进行任何的词法分析之前,%之间的内容已经被替换掉了,这就是网上那些批处理教程中所谓的“第一次预处理”。

替换完%之后,就可以进行词法分析了。词法分析(lexical analysis)是计算机科学中将字符序列转换为单词(Token)序列的过程。CMD的词法分析器会遍历LexBuf的字符,如果当前字符不是分隔符(Delimiter),就复制到另一个缓冲区中(称之为TokBuf)。

批处理的分隔符可以分为两种,一般分隔符和特殊分隔符。

一般分隔符为空白字符(Whitespace),包括空格(0×20)和0×09到0x0D之间的字符;还有逗号(,),分号(;),如果等号(=)没有特别意义的话也包括等号,此外还有C语言中的字符串结束符号NUL(0×00)。

特殊分隔符是指在批处理中有特殊意义的字符,包括&|<>,在某些特定语境中的()。

回车符(0x0D)会被直接丢弃掉,不会被复制到TokBuf。

扫描到双引号"的时候,会改变一个双引号标志位,表示之后的字符都是普通字符,没有特殊的含义,直到与之配对的"或者换行(\n)为止。

    rem 正确,双引号中的字符没有特殊含义
    set "$=&|<>()"
    rem 正确,没有匹配的双引号也没关系
    set "$=&|<>()
    rem 错误,双引号外的特殊字符
    set "$=&|<>()"&|<>()
    pause

如果碰到转义字符^,就会直接复制它的下一个字符到TokBuf,而不会检测它是否为分隔符,是否有特殊意义。

所以set $=^&^|^<^>是不会有什么问题的。

转义字符^还有一个奇怪的特性,如果它的下一个字符是换行符(\n),那么会直接丢弃它,并且复制它(\n)后面的字符到TokBuf,这就是为什么我们可以这样获取换行符的原因:

    @echo off
    setlocal enabledelayedexpansion
    set lf=^


    echo http://!lf!demon.tw
    pause

注意使用PC格式的换行符保存。

    Offset      0  1  2  3  4  5  6  7   8  9  A  B  C  D  E  F

    00000000   40 65 63 68 6F 20 6F 66  66 0D 0A 73 65 74 6C 6F   @echo off  setlo
    00000010   63 61 6C 20 65 6E 61 62  6C 65 64 65 6C 61 79 65   cal enabledelaye
    00000020   64 65 78 70 61 6E 73 69  6F 6E 0D 0A 73 65 74 20   dexpansion  set
    00000030   6C 66 3D 5E 0D 0A 0D 0A  0D 0A 65 63 68 6F 20 68   lf=^      echo h
    00000040   74 74 70 3A 2F 2F 21 6C  66 21 64 65 6D 6F 6E 2E   ttp://!lf!demon.
    00000050   74 77 0D 0A 70 61 75 73  65                        tw  pause

^后面是三个0x0A(换行符\n的十六进制,0x0D会被丢弃掉,故不考虑它),第一个0x0A会被丢弃掉,第二个0x0A会被当成set命令参数的一部分,而第三个0x0A是分隔符,表示命令结束。所以CMD最终运行的命令是set lf=[0x0A](换行符看不见,只好这样表示。)

当开启了变量延迟(ENABLEDELAYEDEXPANSION)的时候,如果命令中存在感叹号!,那么在命令解析完毕之后运行之前,会对^进行二次转义,并且会将!之间的内容替换为对应的环境变量,单独的!号会被丢弃,这就是所谓的“第二次预处理”。

最后让我们分析一下文章开头那段代码:

    @echo off
    set ^&=setlocal enabledelayedexpansion
    :: 解析后为 set &=setlocal enabledelayedexpansion
    set ^^^^^hero=^^^^^&p
    :: 解析后为 set ^^hero=^^&p
    set ^au=^^^au
    :: 解析后为 set au=^au
    set ^^^^^^^^^=障眼法
    :: 解析后为 set ^^^^=障眼法
    %&%
    :: 替换%后为 setlocal enabledelayedexpansion
    set ^^^^^se=^^^se!
    :: set ^^se=^se!
    :: 由于开启了变量延迟,会再转义一次
    :: set ^se=se
    echo %^^^^%!%^^hero%!au%^se%
    :: 首先替换掉%再解析命令
    :: echo 障眼法!^^&p!ause
    :: 解析命令,&是连接操作符,连接两个命令
    :: 第一个命令解析后为 echo 障眼法!^
    :: 第二个命令解析后为 p!ause
    :: 由于开启了变量延迟,替换掉!(找不到匹配的! 丢弃掉)
    :: echo 障眼法
    :: pause
作者: CrLf    时间: 2012-8-3 18:20

俺了解得比较原始,只是靠经验从批本身来总结...
虽然感觉上楼主的观点能解释经验,应该也是楼主幸苦反编译所得,但是可否像前几次那样贴出截图(同尺寸下图的体积最好小一点,省空间啊哈)以便他人理解和勘误?

对汇编和c都只了解了一点点,可能很难参与讨论,准备私信呼叫 qzw 他们来议论和补充。
作者: forfiles    时间: 2012-8-3 23:22

虽不懂但觉历
作者: qzwqzw    时间: 2012-8-4 09:54

对cmd的debug实际上是一件吃力不讨好的事情
我自己很快就放弃这方面的努力了
所以很佩服楼主有这样的耐心
也很理解楼主不贴过程截图的苦衷

文中提到的
“转义字符^还有一个奇怪的特性,如果它的下一个字符是换行符(\n),那么会直接丢弃它,并且复制它(\n)后面的字符到TokBuf”

这个其实不难解释
因为cmd的设计者是想把^也作为续行符使用的
这个类似于C语言字符串中的/符号
所以才会有以下的功能特性

C:\WINDOWS>c^
More? d
C:\WINDOWS

C:\WINDOWS>
作者: plp626    时间: 2012-8-4 15:59

以前大家大都是从“现象反推理论”,是物理实验的方**,最大的缺点就是“永远不能证明某个猜想是对的,只能在不断的否定中完善猜想”。
很期待楼主能把这些专业调试工具普及下, 让更多的人用先进的方**去研究本质问题, 让更多的人参与到这个话题中来。。。
作者: plp626    时间: 2012-8-6 00:00

研究cmd对批处理文件的“预处理”解析,楼主用的是“先进设备”, 确实值得大家学习,
但就本文来说,要真正从文件读取,到api调用, 楼主得出的结论还很初步, 远远没有看到概貌的迹象。。。
这确实需要更多的爱好者用 参与到这个 话题中。。。楼主孤军奋战怕是坚持不久。。。
作者: CrLf    时间: 2013-7-30 18:26

如果你以Unix格式换行符(\n)保存上面的代码,那么情况会有所不同,因为不存在"\n\r"或者"\r\n"的组合,所以AnsiBuf中的内容会全部转成Unicode保存在UniBuf,CMD只需要读取一次文件(上面是三次)就能处理完整个脚本!


这里好像有点问题噢,按我的理解,楼主的意思是以 LF 为换行编码保存运行 bat 时,cmd 读入 MAX_BUFFER_SIZE 的内容后无论其中有多少个复合语句都只在内存中解析,也就是说如果 %~z0 小于 MAX_BUFFER_SIZE,cmd 应该是不知道同等体积的 %0 内容是否有变的,可运行这个还是能看到 pause 的效果:
  1. @echo off
  2. echo echo abc
  3. (findstr "e ^$" %0 & echo ^
  4. pause)>$
  5. move /y $ %0
  6. 12345
复制代码
含有 50000 个 rem 的批处理分别存为 LF 和 CRLF,测试观察到的用时相差无几,在小范围内上下浮动,用括号分别囊括后用时骤减。
也就是说即使用了 LF,貌似也像哪位前辈解释的那样:cmd 每读入一次只处理一个符合语句

测试环境 win7
作者: Demon    时间: 2013-8-15 20:17

这里好像有点问题噢,按我的理解,楼主的意思是以 LF 为换行编码保存运行 bat 时,cmd 读入 MAX_BUFFER ...
CrLf 发表于 2013-7-30 18:26


的确有问题,因为转成Unicode以后还要以\n截断一次,所以用\n换行并没有效率的提升。




欢迎光临 批处理之家 (http://bathome.net./) Powered by Discuz! 7.2