Conifer Productions logoConifer Productions

Ada 实现的 ASCII lookup utility

在使用 20 世纪 80 年代和 90 年代的旧数字合成器(以及一些较新的合成器)时,我经常需要快速查找看起来像是 ASCII 字符代码的字节的标识。例如,在 MIDI System Exclusive 格式文件中,通常会有声音补丁或“音色”的名称。在编写程序来处理这些格式时,快速检查一下很方便,因为即使(或者尤其?)从事这项工作近 40 年,我仍然没有记住所有的 ASCII 码。

为什么是 ASCII 而不是 Unicode?原因很简单,这些旧格式是在 Unicode 甚至还不存在,或者还处于早期阶段时创建的。一个全面的 Unicode 查询工具会很棒,但我还没有找到,甚至没有很努力地去找。

我之前用 Rust 做过一个类似的工具,你可以在 GitHub 上找到 asc-rs。但是,我想学习更多的 Ada,并展示如何使用 GNAT 生成这种工具。

你不需要了解太多的 Ada 就能看懂这个教程,但我假设你知道如何用其他一些语言编程,比如 Java、C++、Python 或 Rust,仅举几个例子。

准备你的 Ada 工具

在本教程中,我将在一个类 Unix 环境(Linux 或 macOS)的终端中工作,所以如果你使用的是 Microsoft Windows,你需要调整为 Command Prompt 或 PowerShell。该工具本身将是一个命令行工具,没有图形用户界面,因此它应该在这些主流操作系统上具有高度的可移植性。

要安装 Ada 开发工具,我建议你参考 Adel Noureddine 的 Ada on Windows and Linux: an installation guide。你需要一个 Ada 编译器 (GNAT) 和一个构建系统 (GPRBuild)。对于这个工具,我不会使用 Alire 包管理器,因为我尽量不使用任何外部库,只使用标准的 Ada。这是一个很小的程序,最好只使用 Ada,不要引入包管理器的复杂性(并不是说使用 Alire 很复杂,但它是一个额外的步骤,可以等到你处理更实质性的东西时再使用)。

安装工具后,在你的开发机器上创建一个名为 asc 的子目录,这样我们就可以开始了。

工具的基本框架

最终,我希望程序像这样工作:当我没有参数地调用它时,它会打印完整的 ASCII 表。这将产生 127 行,每一行都包含字符代码(十进制、十六进制、八进制和二进制)以及字符的名称和其可见表现形式(除了某些会搞乱显示的打印控制字符)。

当你使用一个参数调用程序时,该参数将被解释为一个数字字符代码值。如果参数以 0x 开头,它将被视为十六进制格式的字符代码。类似地,以 0b 开头表示二进制,以 0o 开头表示八进制。如果没有这样的前缀,则假定为十进制。

以下是一些程序在使用中的示例,一旦它最终完成:

获取关于十六进制 ASCII 字符代码 41H 的信息:

% asc 0x41
 65 41 1000001 101 A

获取关于二进制 ASCII 字符代码 1010B 的信息:

% asc 0b1010
 10 0A 0001010 012 LF    
  

从输出可以看出,二进制字符代码有七位,因为 ASCII 是一个 7 位代码。

请注意,该程序不接受实际字符作为参数。这是经过设计的;一些字符可能被 shell 解释,并且可以通过将命令与 grep 结合来查询任何字符。例如,要查找关于字符 N 的信息:

% asc | grep "N"
 0 00 0000000 000 NUL
 5 05 0000101 005 ENQ
 21 15 0010101 025 NAK
 22 16 0010110 026 SYN
 24 18 0011000 030 CAN
 78 4E 1001110 116 N

好吧,也许信息太多了 - 尝试使用正则表达式的 grep,该表达式匹配行尾的空格和 N:

% ./asc | grep " N$"
 78 4E 1001110 116 N

好多了!

第一个初步的 Ada 版本

程序文本在文件 asc.adb 中。扩展名代表 "Ada body"。有一个库单元,即一个名为 Asc 的过程。它还包含一个名为 Print_Table 的嵌套过程,如果在没有命令行参数的情况下调用,则会调用该过程。第一个版本([GitHub](https://coniferproductions.com/ada/ohyes/ascii-lookup-utility/https:/github.com/coniferprod/asc-ada/blob/main/v1/asc.adb))看起来像这样:

with Ada.Text_IO;
  with Ada.Command_Line;
    
  procedure Asc is  
    -- Print the full ASCII table.
    procedure Print_Table is
    begin
     Ada.Text_IO.Put_Line ("(table goes here)");
    end Print_Table;
    
  begin
    -- If there are no command line arguments, 
    -- just print the whole table and exit.
    if Ada.Command_Line.Argument_Count < 1 then
     Print_Table;
     return;
    end if;
  
    -- Show the first command line argument
    Ada.Text_IO.Put ("First argument = '" &
     Ada.Command_Line.Argument (1) & "'");
    Ada.Text_IO.New_Line;
  end Asc;
  

命令行参数处理将在稍后实现。

要构建程序,你可以使用 gnatmake

% gnatmake asc

结果应该是在当前目录中的一个可执行文件。如果你运行它,你应该只看到文本 "(table goes here)"。

打印 ASCII 表

由于处理命令行参数是更困难的任务,让我们从打印完整的 ASCII 表开始。

Ada 的 Character 数据类型涵盖了 ISO Latin-1 字符集,但我们只需要 ASCII。包 Ada.Characters.Handling 定义了 Character 的一个子类型,称为 ISO_646,这正是我们想要的。它在标准库中定义如下:

subtype ISO_646 is
  Character range Character'Val(0) .. Character'Val(127);

为了方便,我们可以重命名这个类型,但让我们只使用原始名称。

程序的第二个版本([GitHub](https://coniferproductions.com/ada/ohyes/ascii-lookup-utility/https:/github.com/coniferprod/asc-ada/blob/main/v2/asc.adb))引入了另一个嵌套过程 Print_Row,它处理每个 ASCII 字符的信息行的打印。现在 Print_Table 过程已经进行了扩充,可以循环遍历 ISO_646 类型中的所有字符。

with Ada.Text_IO;
  with Ada.Command_Line;
  with Ada.Characters.Handling; use Ada.Characters.Handling;
  
  procedure Asc is
  
    -- Print a row for an ASCII character.
    procedure Print_Row (Char : ISO_646) is
    begin
     Ada.Text_IO.Put_Line ("Row for character " & Char'Image);
    end Print_Row;
  
    -- Print the full ASCII table.
    procedure Print_Table is
    begin
     for Char in ISO_646'Range loop
       Print_Row (Char);
     end loop;
    end Print_Table;
  
  begin
    -- If there are no command line arguments, 
    -- just print the whole table and exit.
    if Ada.Command_Line.Argument_Count < 1 then
     Print_Table;
     return;
    end if;
  
    -- Show the first command line argument
    Ada.Text_IO.Put ("First argument = '" &
     Ada.Command_Line.Argument (1) & "'");
    Ada.Text_IO.New_Line;
  end Asc;
  

如果你现在编译并运行这个程序,它会打印出 127 行,就像这样(这里省略了大部分):

Row for character NUL
Row for character SOH
Row for character STX
.
.
.
Row for character '~'
Row for character DEL

解决了这个问题之后,是时候继续构建实际的行了。

行输出,Ada 风格

每一行都应该有四种进制的字符代码,后面跟着字符名称。我们可以使用 Ada.Integer_Text_IO 包中提供的工具来打印这些信息。

这一次,只显示了修改后的 Print_Row 过程(完整的程序文本请参见 [GitHub](https://coniferproductions.com/ada/ohyes/ascii-lookup-utility/https:/github.com/coniferprod/asc-ada/blob/main/v3/asc.adb)):

    -- Print a row for an ASCII character.
    procedure Print_Row (Char : ISO_646) is
     use Ada.Text_IO;
     use Ada.Integer_Text_IO;
    
     -- The ordinal value of the character
     Value : constant Integer := ISO_646'Pos (Char);
    begin
     Put (Item => Value, Width => 3, Base => 10);
     Put (" ");
     Put (Item => Value, Width => 2, Base => 16);
     Put (" ");
     Put (Item => Value, Width => 7, Base => 2);
     Put (" ");   
     Put (Item => Value, Width => 3, Base => 8);
     Put (" ");   
     Put (Item => Char'Image);
     New_Line;
    end Print_Row;    
  

默认打印输出不尽如人意。默认情况下,Put 过程首先打印出基数,然后打印出字符代码的值,夹在哈希字符之间(除了 10 进制):

 65 16#41# 2#1000001# 8#101# 'A'

我们想要摆脱基数和哈希字符,以及可打印字符周围的单引号(像 LF 这样的控制字符的名称已经显示)。

自定义数字打印

这里的解决方案是创建一个过程,用恒定的宽度和从左边零填充,以各种基数打印字符代码值。

我们严格处理 7 位 ASCII 代码值,所以让我们创建一个子类型:

subtype ASCII_Code is Integer range 0 .. 127;

Ada.Integer_Text_IO 包中的过程使用的数字基数使用 Ada.Text_IO.Number_Base 类型。我们可以使用子类型谓词将允许的基数进一步限制为 2、8、10 和 16:

subtype Our_Base is Ada.Text_IO.Number_Base with
  Static_Predicate => Our_Base in 2 | 8 | 10 | 16;

现在让我们创建一个新的过程 Print_Value,它将字符代码、所需的宽度和要使用的数字基数作为参数。它将首先将值输出到一个字符串中,然后提取应该打印的部分。(完整的程序文本在 GitHub。)

    -- Print a non-base-10 value.
    -- Based on ideas found here: https://stackoverflow.com/a/30423877
    procedure Print_Value (Value : ASCII_Code; Width : Positive; Base : Our_Base) is
     -- Make a temporary string with the maximum length (of 2#1111111#)
     Temp_String : String (1 .. 10);
  
     First_Hash_Position : Natural := 0;
     Second_Hash_Position : Natural := 0;
    begin
     -- Get base 10 out of the way first. Just put it out.
     if Base = 10 then
       Ada.Integer_Text_IO.Put (Item => Value, Width => 3);
       return;
     end if;
  
     -- Put the ASCII code value in the specified base 
     -- into the temporary string. Since we are not putting 
     -- a base 10 value, we know there will be hash characters.
     Ada.Integer_Text_IO.Put (To => Temp_String, Item => Value, Base => Base);
  
     -- Get the first hash position, starting from the front
     First_Hash_Position := Index (Source => Temp_String, 
       Pattern => "#", From => 1, Going => Forward);
  
     -- Get the second hash position, starting from the back
     Second_Hash_Position := Index (Source => Temp_String,
       Pattern => "#", From => Temp_String'Length, Going => Backward);
  
     -- Put the part between the hash positions, zero-padded from the left
     Ada.Text_IO.Put (
       Tail (
        Source => Temp_String (First_Hash_Position + 1 .. Second_Hash_Position - 1),
        Count  => Width,
        Pad   => '0'));
    end Print_Value;  
  

我们首先处理十进制字符代码值。它不需要任何特殊处理,所以我们只是在一个 3 个数字的字段中打印出来,并且我们会自动获得用空格从左边填充。

对于其他基数,我们利用了我们将产生的最长值有 10 个字符的事实。这将是任何带有基数和哈希字符的 7 位二进制数。所以我们需要一个长度最大为此长度的临时字符串。我们可以使用 Ada.Integer_Text_IO.Put (To, Item, Base) 重载将字符代码输出到临时字符串中。

我们可以使用 Ada.Strings.Fixed.Index 函数找到临时字符串中第一个和第二个哈希字符的位置,分别向前和向后搜索。

最后,我们可以使用 Ada.Strings.Fixed.Tail 函数提取相关部分(哈希字符之间)并显示它。请注意,字符串索引从 1 开始,而不是 0!

还要注意,我们使用了 Ada.Strings.Fixed 包中的过程和函数,而没有使用它们的前缀,因为这可能会非常乏味。在这个大小的程序中,我认为我们可以通过添加 use 子句来解决这个问题。这是程序文本顶部的完整的 withuse 子句列表:

with Ada.Text_IO;
with Ada.Command_Line;
with Ada.Characters.Handling; use Ada.Characters.Handling;
with Ada.Strings; use Ada.Strings;
with Ada.Strings.Fixed; use Ada.Strings.Fixed;
with Ada.Integer_Text_IO;

我们仍然需要更新 Print_Row 过程以使用新的 Print_Value 过程。如果字符是控制字符,我们还打印字符名称,否则打印实际字符:

    -- Print a full row for the character: decimal, hexadecimal, binary,
    -- octal, and the character name or literal.
    procedure Print_Row (Char : ISO_646) is
     use Ada.Text_IO;
  
     -- The ordinal value of the character
     Value : constant ASCII_Code := ISO_646'Pos (Char);
  
     -- The separator between the fields
     Blanks : constant String := 2 * Space;
    begin
     Print_Value (Value, Width => 3, Base => 10);
     Put (Blanks);
     Print_Value (Value, Width => 2, Base => 16);
     Put (Blanks);
     Print_Value (Value, Width => 7, Base => 2);
     Put (Blanks);   
     Print_Value (Value, Width => 3, Base => 8);
     Put (Blanks);
  
     if Is_Control (Char) then  
       Put (Item => Char'Image);
     else
       Put (Char);
     end if;
  
     New_Line;
    end Print_Row;  

如果你尝试使用 Print_ValueOur_Base 类型的静态谓词中声明之外的某些其他基数打印值,那么 Ada 编译器会首先警告你:

asc.adb:57:47: warning: static expression fails static predicate check on "Our_Base" [enabled by default]
asc.adb:57:47: warning: expression is no longer considered static [enabled by default]

但只有当你在程序文本的开头指定 pragma Assertion_Policy (Check) 时才会这样。你还会在运行时得到一个 Assertion_Error

raised ADA.ASSERTIONS.ASSERTION_ERROR : Static_Predicate failed at asc.adb:57

如果你错误地为 Width 参数指定零或负值,你将从编译器获得更多警告,因为它使用 Positive 类型声明。

Ada 的隐藏的瑰宝可以在 Ada.Strings.Fixed 包中找到:乘法运算符是为整数和字符串定义的。在 Print_Row 过程中,我们定义了一个常量:

-- The separator between the fields
Blanks : constant String := 2 * Space;

然后我们使用它通过 Put (Blanks) 在调用 Print_Value 之间分隔字符代码值。这使得程序更具可读性,并且还为我们提供了一种方便的方法,只需在一个地方将数字 2 更改为其他内容(比如 4),就可以更改空格的数量,而不是寻找 Put (" ") 行。在这里,Space 实际上是 Ada.Characters.Latin_1.Space,但由于 use 子句,可以这样使用。

处理命令行参数

现在我们有一个可以打印 ASCII 表的工具,其中的字符代码采用四种不同的数字基数。以下是输出的摘录:

 65 41 1000001 101 A
 66 42 1000010 102 B
 67 43 1000011 103 C

还剩一件事要做:处理命令行参数(如果存在)。这将把打印输出缩小到仅关于以任何已知数字基数指定的单个字符代码的信息。

我们将从一些助手开始。如果给定的字符串以给定的前缀开头,Starts_With 函数返回 true,否则返回 false。它使用 Ada.Strings.Fixed.Index 函数。Print_Error 过程只是将消息打印到标准错误设备。

    -- Helper function to find out if a string starts with a prefix.
    function Starts_With (S : String; Prefix : String) return Boolean is
    begin
     return (Ada.Strings.Fixed.Index (Source => S, Pattern => Prefix) /= 0);
    end Starts_With;
  
    -- Print an error message to the standard error device.
    procedure Print_Error (Message : String) is
    begin
     Ada.Text_IO.Put_Line (Ada.Text_IO.Standard_Error, Message);
    end Print_Error;  

我们需要一些变量来进行参数处理,所以我们将使用一个 declare 块来清楚地表明这些变量仅在我们实际需要处理参数时才使用。

由于我们此时知道我们至少有一个命令行参数,我们可以将其保存到 Arg 变量并检查它是否以我们支持的前缀之一开头。我们将相应地设置 Start_Position 变量,以便我们知道实际数字部分从哪里开始。

为了解释任何可能的前缀之后的实际数字,我们使用 Ada 自己的机制,我们之前在 Print_Value 过程中规避了该机制:构造一个带有基数和哈希字符的字符串,并使用 Ada.Integer_Text_IO.Get 过程从中读取。

这里基本上有两件事可能出错:字符代码参数是完全的垃圾(任何给定基数都不是数字),或者它超出了 ASCII 代码范围。

如果参数错误,Ada.Integer_Text_IO.Get 过程将引发 Ada.Text_IO.Data_Error 类型的异常。我们在 declare 块的异常处理程序中处理此问题。我们只是将消息打印到标准错误。

如果我们得到一个值,我们检查它是否在 ASCII_Code 类型的范围内。如果成功,我们打印该字符代码的行。如果失败,我们打印一条错误消息。

以下是完整的 declare 块(完整的程序文本在 [GitHub](https://coniferproductions.com/ada/ohyes/ascii-lookup-utility/https:/github.com/coniferprod/asc-ada/blob/main/v5/asc.adb)):

    declare
     Arg : constant String := Ada.Command_Line.Argument (1);
  
     -- The start position of the number part, after any prefix.
     -- The most common case is 3 (after a prefix line "0x").
     Start_Position : Positive := 3;
  
     -- The position of the last character that the
     -- Get procedure read (required but ignored here)
     Last_Position_Ignored : Positive;
  
     -- The actual number we get out of the argument
     Value : Integer;
  
     -- The base for the argument, defaults to decimal
     Base : Our_Base := 10;
    begin
     if Starts_With (Arg, "0x") then
       Base := 16;
     elsif Starts_With (Arg, "0b") then
       Base := 2;
     elsif Starts_With (Arg, "0o") then
       Base := 8;
     else -- no prefix, most likely a decimal number
       Start_Position := 1;
     end if;
  
     -- Construct an image like "10#65#" or "16#7E#" and parse it.
     Ada.Integer_Text_IO.Get (
       From => Base'Image & "#" & Arg (Start_Position .. Arg'Length) & "#",
       Item => Value,
       Last => Last_Position_Ignored);
  
     if Value in ASCII_Code then
       Print_Row (Character'Val (Value));
     else
       Print_Error ("Character code out of range: " & Arg);
     end if;
    exception
     when Ada.Text_IO.Data_Error =>
       Print_Error ("Error in argument");
    end;
  

这样,我们就完成了!一些 Ada 特定的要点需要注意:

如果你需要 ASCII 码查找,并且想学习 Ada 编程,希望这是一个有用的工具,并且具有指导意义。

该程序的最终版本可以在 GitHub 上找到。

发布时间:2025-04-10

版权所有 © 2012–2025 Conifer Productions Oy