做嵌入式调试、协议解析的朋友经常会遇到这种场面:串口日志里蹦出一串\x02、\x11,得靠一张 ASCII 码速查表才能对上号。我以前也是从网上找表,图片放大就糊、网页里广告比表格还多,控制字符那一栏还经常缺失,更别说按自己的习惯排序和筛选了。后来干脆用 Python 写了个小脚本,把 0 到 127 这 128 个码位全部生成出来,想要终端表格就打印表格,想导成 CSV、Markdown、JSON 或者带搜索框的 HTML 页面都行,整个过程三分钟不到,拿到手的是一份完全属于自己的 ASCII 码速查表。它解决的不是“ASCII 是什么”这种概念问题,而是把码位、缩写、进制形态、可打印性、中文含义集中在同一张表里,随时能查、随时能改、随时能打印成卡片贴在显示器边上。刚接触底层调试的新手可以拿它当参照系,常年跟字节流打交道的老手也值得留一份在自己机器上,毕竟网上的表你改不了一行。
1. 先想清楚:这张表到底要解决什么问题
1.1 网上下载的 ASCII 表为什么总是不好用
大多数人第一次找 ASCII 表,都是搜到一张图或者一个静态网页,存进收藏夹,下次要用再翻出来。这种用法在只查A是 65 的时候没问题,一旦遇到控制字符就抓瞎了。我统计过自己过去半年的需求,真正需要查的可打印字符不到三成,剩下七成全是查0x0D是回车还是换行、0x1B是不是 ESC、0x7F到底是 DEL 还是别的什么。静态表最大的毛病就在这里:控制字符那一栏经常只有缩写没有中文解释,有的干脆把 0 到 31 全画成空白,因为“反正也打不出来”。
第二个毛病是不支持筛选。调试不同协议时关注的范围完全不一样,解析二进制帧头时只关心 0 到 32,写正则表达式时只关心 32 到 126,做字符编码迁移时又要把十六进制和二进制对着看。静态表没法按范围过滤,也没法一次打印出“只看可打印字符”的版本。第三个毛病是格式固定,你在终端里想快速grep一下,或者在 Markdown 笔记里嵌一段,静态图片和网页都做不到。
这三个问题凑在一起,结论就很清楚了:需要的不是一个“能看”的 ASCII 码速查表,而是一个“能按需生成”的生成器。数据只有 128 行,写在代码里也就几十行常量,但生成器可以输出任意格式、任意范围、任意详略,这才是真正解决问题的形态。
1.2 我的方案取舍:一个脚本,五种输出
确定要自己生成之后,第一件事是定输出格式。我最初只想做终端打印,后来发现不同场景下的诉求差别很大,最终定了五种:终端表格用于即时查询,CSV 用于丢进电子表格软件做进一步加工,Markdown 用于嵌进笔记和项目文档,JSON 用于程序消费和二次开发,HTML 用于做成一个可以本地打开、带搜索框的速查页。
为什么不干脆做一个网页应用?因为打开成本太高。查一个0x1B要开浏览器、等页面加载、在收藏夹里翻半天,而终端里一条命令就能出结果。为什么不用现成的库?Python 标准库里已经有string模块的printable、unicodedata模块的name,但没有现成的“带进制转换和中文说明的完整表”生成器,自己拼装反而更快,而且零依赖,拷贝到任何一台有 Python 3 的机器上都能跑。
还有一个关键取舍是数据来源。控制字符的缩写和含义没法从标准库直接拿到,必须自己维护一张字典。我见过有人用chr(i).isprintable()来判断,这个思路对可打印字符有效,但控制字符的缩写(NUL、SOH、STX 这些)只能人工录入。好在只有 33 条(0 到 31 加上 127),一次性写死,后面所有输出格式都复用这一份数据,维护成本可以接受。
1.3 输出格式选型对比
把五种格式放在一起看,各自的适用边界其实很清楚,选错了会白白浪费时间。下面这张表是我实际用下来之后总结的对照,可以当作选型时的参考。
| 输出格式 | 生成速度 | 适用场景 | 主要优点 | 需要注意的点 |
|---|---|---|---|---|
| 终端表格 | 最快 | 临时查一个码位、脚本里直接看 | 零文件、零依赖、即跑即看 | 中文字符对齐要单独处理 |
| CSV | 快 | 丢进电子表格排序、筛选、做透视 | 通用性最强,几乎任何工具都能读 | 文件编码要带 BOM,否则中文字段乱码 |
| Markdown | 快 | 写进笔记、项目 README、技术文档 | 纯文本可 diff,方便版本管理 | 管道符和反斜杠要做转义 |
| JSON | 快 | 被其他程序读取、做成接口数据 | 结构清晰,字段可扩展 | 体积比 CSV 大,人眼阅读不友好 |
| HTML | 略慢 | 本地速查页、打印成纸质卡片 | 可加搜索框,浏览器里体验最好 | 特殊字符要转义,否则表格错位 |
我自己的习惯是:日常查询用终端,写文档时生成 Markdown,需要打印贴在工位上就生成 HTML 再走浏览器打印。CSV 和 JSON 属于“备着”,真正用到的时候往往是别人要,或者要把表喂给另一个脚本做批量处理。这套组合覆盖了我过去几年遇到的所有需求,暂时没发现需要第六种格式的场景。
2. 别背了,ASCII 的排列规律其实很有逻辑
2.1 0 到 31 和 127:控制字符的四个分组
很多人记不住控制字符,是因为把它们当成 33 个孤立的值硬背。其实这 33 个码位是有分组的,按功能大致可以分成四类,记住分组比记住单个值高效得多。
第一组是 0 到 6,加上 16 到 21,属于传输控制类,包括 NUL(空字符)、SOH(标题开始)、STX(正文开始)、ETX(正文结束)、EOT(传输结束)、ENQ(询问)、ACK(确认)、NAK(否认)这些。这一组的历史背景是早期的电传打字机通信协议,现在真正还在用的主要是 NUL 和 ACK/NAK,其余多见于老协议的帧结构定义里。
第二组是 7 到 13,属于格式控制类,也就是最常用的那几个:BEL(响铃,7)、BS(退格,8)、HT(水平制表,9)、LF(换行,10)、VT(垂直制表,11)、FF(换页,12)、CR(回车,13)。这七个是真正需要肌肉记忆的,因为每天都在跟它们打交道。我个人的记法是记住\r\n是 13 和 10,\t是 9,\b是 8,剩下的按顺序推。
第三组是 14 到 31,属于设备控制和分隔符类,包括 SO(移出)、SI(移入)、DLE(数据链路转义)、DC1 到 DC4(设备控制)、CAN(取消)、EM(介质结束)、SUB(替换)、ESC(转义,27)、FS/GS/RS/US(四级分隔符)。这一组里 ESC 用得最多,剩下的在文本协议里偶尔能见到 FS 到 US 作为字段分隔。
第四组只有一个,127,也就是 DEL(删除)。它排在最末尾而不是和前面连在一起,原因是早期纸带打孔时代,DEL 的用途是把纸带上对应位置的孔全部打掉来“擦除”一个字符,所以它的二进制是01111111,七个 1,物理上就是全打孔。理解了这个来历,127 这个看似突兀的值就再也忘不掉了。
2.2 32 到 126:可打印字符的五段式布局
可打印区间 32 到 126 一共 95 个字符,看似杂乱,实际上排布得非常规整,按顺序是五段:空格、标点区、数字区、大写字母区、小写字母区,中间穿插了几段标点。
具体来说,32 是空格。33 到 47 是第一批标点符号,从感叹号!一直到斜杠/,这一段在键盘上大致对应数字键的上档位。48 到 57 是十个数字,0到9,这是整个表里最规整的一段。58 到 64 是第二批标点,从冒号:到 at 符号@。65 到 90 是 26 个大写字母,A到Z。91 到 96 是第三批标点,从方括号[到反引号`。97 到 122 是 26 个小写字母,a到z。123 到 126 是最后一批标点,从花括号{到波浪号~。
这个布局最妙的地方在于,三段标点(33 到 47、58 到 64、91 到 96、123 到 126)把数字和字母隔开,形成清晰的边界。你在十六进制视图里看到0x30到0x39,闭着眼也知道是数字;看到0x41到0x5A就知道是大写字母。这种“看到区间就知道类型”的特性,是靠背单个值永远得不到的直觉。
2.3 三条能省下大量记忆的换算关系
真正值得记的换算关系只有三条,掌握之后绝大多数查表动作都可以在脑子里完成。
第一条:大写字母和小写字母相差 32。A是 65,a是 97,差 32,也就是十六进制的0x20。这条关系在做大小写转换时极其有用,等价于把二进制的第 5 位(从 0 数起)置 1 或清 0。比如a的二进制是01100001,把第 5 位清零就变成01000001,也就是A。
第二条:数字字符和它的数值相差 48。0是 48,1是 49,一直到9是 57。所以字符'7'减去'0'得到整数 7,这是所有语言里char - '0'这种写法的底层依据。十六进制里0x30到0x39对应十个数字,也很好记,因为0x30的低四位正好是 0 到 9。
第三条:字母在字母表里的位置可以从A推导。A是0x41,a是0x61,所以第 n 个字母(从 0 开始数)就是0x41 + n和0x61 + n。比如F是第 5 个,0x41 + 5 = 0x46,对照一下确实是 70。这条规律让我在写十六进制转储工具时经常直接心算出对应字符,省掉不少查表时间。
2.4 进制换算:十六进制为什么是首选
同一个码位有四种常见表示:十进制、十六进制、八进制、二进制。生成表的时候四种都列出来,是为了对照方便,但日常使用中十六进制出现频率远高于其他三种,原因有两个。
第一,十六进制和二进制是直接对应的,一位十六进制对应四位二进制,不需要做除法。看到0x41就能直接展开成0100 0001,反过来也一样。查高位、查标志位的时候,这种直接映射比十进制方便太多。第二,ASCII 表的关键边界在十六进制下都是整数:控制字符是0x00到0x1F,空格是0x20,数字是0x30到0x39,大写字母是0x41到0x5A,小写字母是0x61到0x7A,DEL 是0x7F。所有边界都是整十或整段,记忆负担小。
八进制在现代开发里用得少,但在一些老的协议文档和文件权限表示里还会遇到,所以表里保留一列。二进制列主要用于教学和位运算场景,完整列出八位(高位补零)比七位更直观,也方便和字节对齐。十进制则是给人看的,写业务代码时用得最多。
需要提醒一点:ASCII 严格来说是 7 位编码,范围 0 到 127,第 8 位在原始标准里是做奇偶校验用的。后来各种扩展编码把 128 到 255 也用上了,但那已经不是 ASCII 了,而是各种扩展字符集。生成表的时候如果范围开到 255,最好在表头注明“128 以上不属于标准 ASCII”,避免误导。
3. 完整代码实现:从 15 行到可复用工具
3.1 最小可用版:终端里直接打印
如果只是想快速看一张表,15 行足够。这段代码只用标准库,不需要任何第三方依赖,拷贝到任何 Python 3 环境里都能跑。
# minimal_ascii.py for code in range(128): ch = chr(code) if code < 32 or code == 127: display = "^" + chr(code + 64) if code != 127 else "^?" name = "控制字符" else: display = "(空格)" if code == 32 else ch name = "可打印" print(f"{code:3d} 0x{code:02X} 0o{code:03o} {code:08b} {display:<6} {name}")chr(code + 64)这个写法是控制字符的脱字符表示法,码位 0 对应^@,1 对应^A,依此类推,31 对应^_。这套表示法在很多终端和编辑器里是通用的,比直接打印不可见字符安全得多。127 单独处理成^?,因为127 + 64 = 191,超出了 ASCII 范围。
这段代码能跑,但输出很粗糙:中英文混排会导致列不对齐,控制字符没有具体名称,也没有分类。它适合应急,不适合日常使用。下面的工程版会把这些问题逐个解决。
3.2 工程版第一部分:控制字符表和显示宽度
工程版的第一块是数据部分,也就是那张 33 条的控制字符字典。这张表是整个脚本的核心资产,后面所有输出格式都从这里取数据。
# ascii_sheet.py 第 1 部分:常量与数据 import argparse import csv import io import json import unicodedata from pathlib import Path CONTROL = { 0: ("NUL", "空字符", "字符串终止符,C 语言里的 \\0"), 1: ("SOH", "标题开始", "老式传输协议的帧头标记"), 2: ("STX", "正文开始", "常作为二进制帧的起始字节"), 3: ("ETX", "正文结束", "常作为二进制帧的结束字节"), 4: ("EOT", "传输结束", "表示一次传输完成"), 5: ("ENQ", "询问", "请求对端应答"), 6: ("ACK", "确认", "肯定应答"), 7: ("BEL", "响铃", "终端收到会发出提示音"), 8: ("BS", "退格", "光标左移一位,\\b"), 9: ("HT", "水平制表", "跳到下一个制表位,\\t"), 10: ("LF", "换行", "光标下移一行,\\n"), 11: ("VT", "垂直制表", "早期终端的纵向跳转"), 12: ("FF", "换页", "打印机走纸到下一页,\\f"), 13: ("CR", "回车", "光标回到行首,\\r"), 14: ("SO", "移出", "切换到备用字符集"), 15: ("SI", "移入", "切回默认字符集"), 16: ("DLE", "数据链路转义", "改变后续字节的解释方式"), 17: ("DC1", "设备控制 1", "常用于软件流控恢复"), 18: ("DC2", "设备控制 2", "设备专用"), 19: ("DC3", "设备控制 3", "常用于软件流控暂停"), 20: ("DC4", "设备控制 4", "设备专用"), 21: ("NAK", "否认", "否定应答,请求重传"), 22: ("SYN", "同步空闲", "同步传输时的填充字符"), 23: ("ETB", "传输块结束", "表示一个数据块结束"), 24: ("CAN", "取消", "作废当前传输内容"), 25: ("EM", "介质结束", "存储介质到达末尾"), 26: ("SUB", "替换", "替换掉一个无法识别的字符"), 27: ("ESC", "转义", "引出后续控制序列"), 28: ("FS", "文件分隔符", "四级分隔符中的最高级"), 29: ("GS", "组分隔符", "四级分隔符中的第二级"), 30: ("RS", "记录分隔符", "四级分隔符中的第三级"), 31: ("US", "单元分隔符", "四级分隔符中的最低级"), 127: ("DEL", "删除", "纸带时代用于打掉全部孔位"), } PUNCT_CN = { 32: "空格", 33: "感叹号", 34: "双引号", 35: "井号", 36: "美元符号", 37: "百分号", 38: "和号", 39: "单引号", 40: "左圆括号", 41: "右圆括号", 42: "星号", 43: "加号", 44: "逗号", 45: "连字符", 46: "句点", 47: "斜杠", 58: "冒号", 59: "分号", 60: "小于号", 61: "等号", 62: "大于号", 63: "问号", 64: "at 符号", 91: "左方括号", 92: "反斜杠", 93: "右方括号", 94: "脱字符", 95: "下划线", 96: "反引号", 123: "左花括号", 124: "竖线", 125: "右花括号", 126: "波浪号", }字典的键是十进制码位,值是一个三元组:缩写、中文名、备注。这样设计的好处是,输出终端表格时取缩写和中文名,输出 HTML 时可以把备注也带上,不需要在渲染层再做判断。备注字段我尽量写得具体,比如0x01的“老式传输协议的帧头标记”,比只写“标题开始”有用得多,因为后者看完还是不知道它现在用来干嘛。
紧接着需要一个显示宽度函数。这是中英文混排表格的关键,str.ljust按字符数补齐,遇到中文就会错位,因为一个汉字在终端里占两列。
def disp_width(text): """按终端显示宽度计算字符串长度:全角字符算 2,半角算 1。""" return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in text) def pad(text, width): """按显示宽度右补空格。""" return text + " " * max(0, width - disp_width(text))unicodedata.east_asian_width返回W(宽)、F(全角)、A(歧义)、Na(窄)、N(中性)等值。中文和日文汉字返回W,全角符号返回F,这两个都按 2 算。歧义字符(A)在不同终端下宽度不同,保守起见按 1 算,因为我们的表里很少出现这类字符。这个函数看起来简单,但它是所有对齐问题的基础,值得单独抽出来。
3.3 工程版第二部分:构造数据行与分类
数据准备好之后,下一步是把 0 到 127 逐个转换成统一的行结构。所有输出格式都消费这个结构,避免每种格式各写一套逻辑。
def classify(code): if code < 32 or code == 127: return "控制" if code == 32: return "空白" if 48 <= code <= 57: return "数字" if 65 <= code <= 90: return "大写" if 97 <= code <= 122: return "小写" return "标点" def build_rows(start=0, end=127): rows = [] for code in range(start, end + 1): ch = chr(code) kind = classify(code) if code in CONTROL: abbr, cn, note = CONTROL[code] display = "^?" if code == 127 else "^" + chr(code + 64) print_ok = "否" else: abbr = unicodedata.name(ch, "UNKNOWN") display = "(空格)" if code == 32 else ch cn = PUNCT_CN.get(code, "") if kind == "数字": cn = f"数字 {ch}" elif kind == "大写": cn = f"大写字母 {ch}" elif kind == "小写": cn = f"小写字母 {ch}" note = "" print_ok = "是" rows.append({ "dec": code, "hex": f"{code:02X}", "oct": f"{code:03o}", "bin": f"{code:08b}", "char": ch, "display": display, "name": abbr, "cn": cn, "kind": kind, "note": note, "printable": print_ok, }) return rows这里有一处细节值得说明:unicodedata.name(chr(65))返回的是LATIN CAPITAL LETTER A,这个英文名比缩写A更准确地描述了字符的语义,在查表时有用,但在终端表格里太占地方。我的处理是在终端输出时截断到 24 个字符,在 HTML 和 JSON 里保留完整名称。这种“同一份数据、不同呈现”的策略,比为了对齐把所有地方都截断要合理。
分类函数用的是纯区间判断,没依赖任何库。数字、大写、小写这三个区间的判断顺序有讲究:必须在小写判断之前先判断大写,因为两个区间不重叠,顺序其实无所谓,但可读性上按码位从小到大排更直观。标点作为兜底,覆盖了剩下的所有码位,逻辑上不会漏。
3.4 工程版第三部分:五种渲染器
渲染器是真正把数据变成人能看到的东西的地方。先看终端表格,这是用得最多的一个。
def render_table(rows): headers = ["DEC", "HEX", "OCT", "BIN", "字符", "缩写", "类别", "中文说明"] body = [] for r in rows: body.append([ str(r["dec"]), "0x" + r["hex"], "0o" + r["oct"], r["bin"], r["display"], r["name"][:24], r["kind"], r["cn"], ]) widths = [] for i in range(len(headers)): widths.append(max([disp_width(headers[i])] + [disp_width(row[i]) for row in body])) sep = "-+-".join("-" * w for w in widths) lines = [" | ".join(pad(headers[i], widths[i]) for i in range(len(headers))), sep] for row in body: lines.append(" | ".join(pad(row[i], widths[i]) for i in range(len(headers)))) return "\n".join(lines)widths的计算里用了列表拼接,先放入表头宽度,再放入所有数据行的宽度,取最大值。这种写法的好处是表头和数据谁更宽都不会撑破布局。分隔线用-+-连接,视觉上和|对齐,这是终端表格的常见做法,比用纯横线更清楚。
CSV 渲染器要注意编码问题,这一点后面还会专门讲:
def render_csv(rows): buf = io.StringIO() writer = csv.writer(buf) writer.writerow(["十进制", "十六进制", "八进制", "二进制", "字符", "英文名", "类别", "可打印", "中文说明", "备注"]) for r in rows: writer.writerow([r["dec"], r["hex"], r["oct"], r["bin"], r["display"], r["name"], r["kind"], r["printable"], r["cn"], r["note"]]) return buf.getvalue()Markdown 渲染器只需要注意一件事:表格内容里如果出现竖线,会破坏 Markdown 表格的结构。可打印字符里恰好有|(124),必须转义。
def render_markdown(rows): def esc(text): return str(text).replace("\\", "\\\\").replace("|", "\\|") lines = [ "| 十进制 | 十六进制 | 二进制 | 字符 | 缩写 | 类别 | 中文说明 |", "| --- | --- | --- | --- | --- | --- | --- |", ] for r in rows: lines.append( f"| {r['dec']} | 0x{r['hex']} | {r['bin']} | `{esc(r['display'])}` | " f"{esc(r['name'])} | {r['kind']} | {esc(r['cn'])} |" ) return "\n".join(lines)JSON 最简单,直接用json.dumps序列化行列表,ensure_ascii=False保证中文不被转义成\uXXXX:
def render_json(rows): return json.dumps(rows, ensure_ascii=False, indent=2)HTML 渲染器需要处理特殊字符转义,<、>、&、"这四个字符如果不转义,表格会直接错位。这段代码稍微长一点,因为要把搜索框和过滤脚本一起写进去。
def esc_html(text): return (str(text).replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def render_html(rows): head = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>ASCII 码速查表</title> <style> body { font-family: system-ui, sans-serif; margin: 24px; color: #222; } h1 { font-size: 20px; margin-bottom: 12px; } #q { width: 340px; padding: 6px 10px; border: 1px solid #bbb; border-radius: 4px; } table { border-collapse: collapse; margin-top: 16px; font-size: 13px; } th, td { border: 1px solid #ddd; padding: 4px 10px; text-align: left; } th { background: #f2f2f2; position: sticky; top: 0; } tr:nth-child(even) { background: #fafafa; } td.mono { font-family: Consolas, monospace; } </style> </head> <body> <h1>ASCII 码速查表(0-127)</h1> <input id="q" type="text" placeholder="输入十进制、十六进制或名称,例如 65 / 0x41 / LATIN"> <table id="t"> <thead><tr><th>DEC</th><th>HEX</th><th>OCT</th><th>BIN</th><th>字符</th><th>英文名</th><th>类别</th><th>说明</th></tr></thead> <tbody> """ body = [] for r in rows: body.append( "<tr><td>{}</td><td class=\"mono\">0x{}</td><td class=\"mono\">0o{}</td>" "<td class=\"mono\">{}</td><td class=\"mono\">{}</td><td>{}</td>" "<td>{}</td><td>{}</td></tr>".format( r["dec"], r["hex"], r["oct"], r["bin"], esc_html(r["display"]), esc_html(r["name"]), esc_html(r["kind"]), esc_html(r["cn"]), ) ) tail = """</tbody> </table> <script> var input = document.getElementById('q'); var rows = Array.prototype.slice.call(document.querySelectorAll('#t tbody tr')); input.addEventListener('input', function () { var kw = input.value.trim().toLowerCase(); rows.forEach(function (tr) { tr.style.display = tr.textContent.toLowerCase().indexOf(kw) >= 0 ? '' : 'none'; }); }); </script> </body> </html> """ return head + "\n".join(body) + tail搜索脚本用的是最简单的包含匹配,tr.textContent把整行文本拼起来做一次indexOf。这种写法的优点是实现简单、不用建索引,128 行数据量下性能完全不是问题。缺点是查41会同时匹配到十六进制0x41和二进制里含41的行,但这在速查场景下反而是好事,因为多出来的结果也在合理范围内。
3.5 工程版第四部分:命令行入口
最后是把所有部分串起来的入口。参数设计要克制,只保留真正需要的几个。
def main(): parser = argparse.ArgumentParser(description="生成 ASCII 码速查表") parser.add_argument("--format", choices=["table", "csv", "md", "json", "html"], default="table", help="输出格式") parser.add_argument("--start", type=int, default=0, help="起始码位,默认 0") parser.add_argument("--end", type=int, default=127, help="结束码位,默认 127") parser.add_argument("--only", choices=["all", "printable", "control"], default="all", help="只输出可打印或控制字符") parser.add_argument("--output", default=None, help="输出文件路径,缺省打印到标准输出") args = parser.parse_args() if not (0 <= args.start <= args.end <= 127): parser.error("范围必须在 0 到 127 之间,且起始不大于结束") rows = build_rows(args.start, args.end) if args.only == "printable": rows = [r for r in rows if r["printable"] == "是"] elif args.only == "control": rows = [r for r in rows if r["printable"] == "否"] renderers = {"table": render_table, "csv": render_csv, "md": render_markdown, "json": render_json, "html": render_html} text = renderers[args.format](rows) if args.output: encoding = "utf-8-sig" if args.format == "csv" else "utf-8" Path(args.output).write_text(text, encoding=encoding) print(f"已写入 {args.output},共 {len(rows)} 行", file=sys.stderr) else: print(text) if __name__ == "__main__": main()几个设计决定值得说明。--only参数用三个枚举值而不是两个布尔开关,是因为“只看控制字符”和“只看可打印字符”互斥,用枚举可以避免同时传两个开关产生歧义。CSV 的编码判断放在写文件的地方,因为只有落盘时才需要考虑 BOM 问题,打印到标准输出不需要。提示信息写到stderr而不是stdout,这样在管道里使用时不会污染数据流,这是个容易被忽略但很实用的细节。
范围校验用的是0 <= start <= end <= 127这个链式比较,一行搞定三个条件。如果用户传了超出范围的值,parser.error会打印用法说明并退出,比抛异常友好。
4. 实操跑一遍:从执行到校验
4.1 环境与依赖确认
这套脚本只依赖 Python 标准库,理论上 Python 3.6 以上都能跑。我实际测试过的环境是 Python 3.9 到 3.12,没有遇到兼容性问题。唯一需要注意的是Path.write_text的encoding参数在很老的版本上行为略有差异,如果用的是 3.5 以前的版本,建议换成显式的open写法。
不需要pip install任何东西,这一点在离线环境里特别有用。我经常在一些不能联网的调试机上工作,脚本拷过去就能用,不需要折腾依赖。如果团队里有人不装 Python,可以把生成好的 HTML 直接发给对方,浏览器打开就能用,这也是我坚持要输出 HTML 的原因之一。
检查环境只需要一条命令:
python3 -c "import sys, unicodedata, csv, json; print(sys.version)"输出里能看到版本号就说明基础模块齐全。unicodedata是内置模块,正常情况下不会缺失,极少数精简过的运行环境可能需要单独确认一下。
4.2 实际执行与输出片段
先跑最常用的终端表格,只取可打印部分,看看对齐效果:
python3 ascii_sheet.py --only printable --start 65 --end 90输出大致是这样:
DEC | HEX | OCT | BIN | 字符 | 缩写 | 类别 | 中文说明 ----+------+------+----------+------+-----------------------------+------+-------------- 65 | 0x41 | 0o101 | 01000001 | A | LATIN CAPITAL LETTER A | 大写 | 大写字母 A 66 | 0x42 | 0o102 | 01000010 | B | LATIN CAPITAL LETTER B | 大写 | 大写字母 B 67 | 0x43 | 0o103 | 01000011 | C | LATIN CAPITAL LETTER C | 大写 | 大写字母 C中文列能对齐,靠的就是前面那个disp_width函数。如果把pad换成str.ljust,你会看到“大写字母 A”和“中文说明”这两列在含中文的行上整体右移两格,越往下越乱。
再生成一份完整的 HTML 并打开:
python3 ascii_sheet.py --format html --output ascii.html终端会提示“已写入 ascii.html,共 128 行”。用浏览器打开,顶部有搜索框,输入0x41会立刻筛出A那一行,输入大写会把 26 个大写字母全部列出来。这个搜索框是我用得最频繁的功能,比在终端里滚动看表快得多。
导出 Markdown 用于写文档:
python3 ascii_sheet.py --format md --start 0 --end 31 --output control.md生成的表格可以直接贴进笔记软件,管道符转义已经处理好了。如果直接贴未转义的版本,含|字符的那一行会把表格撑成两列,这是 Markdown 表格最常见的翻车方式。
4.3 结果校验:三种自检方法
生成完表之后,最好做一次校验,确认数据没错。我常用三种方法,成本从低到高。
第一种是往返校验,验证chr和ord互为逆运算,同时确认范围完整:
assert all(ord(chr(i)) == i for i in range(128)) assert len([i for i in range(128) if chr(i).isprintable()]) == 95第二条断言里的 95 是 32 到 126 的字符总数,正好等于0x7E - 0x20 + 1。注意chr(32).isprintable()返回True,空格算可打印字符,这个容易搞错。
第二种是边界抽查,挑几个关键点人工核对:
checks = {0x00: "NUL", 0x09: "HT", 0x0A: "LF", 0x0D: "CR", 0x1B: "ESC", 0x20: "SPACE", 0x30: "0", 0x41: "A", 0x61: "a", 0x7F: "DEL"} for code, expect in checks.items(): row = build_rows(code, code)[0] assert row["name"] == expect, (code, row["name"], expect)第三种是规律校验,利用前面讲过的换算关系反向验证整张表:
rows = build_rows() for a in range(65, 91): assert rows[a]["char"] == chr(a) assert ord(rows[a]["char"]) + 32 == ord(rows[a + 32]["char"]) # 大小写差 32这三段代码加起来不到二十行,建议直接放进脚本里做成--selftest参数。我自己的版本就是这么干的,每次改完渲染逻辑先跑一遍自检,比肉眼看表可靠得多。
4.4 打印成纸质速查卡的排版参数
HTML 版除了在浏览器里查,还有一个用途是打印。我试过几次,128 行单栏打印会占两页,浪费纸也不好用。改成两栏之后一页正好放下,参数如下。
页面设置用 A4 横向,边距 10 毫米。表格样式加column-count: 2和column-gap: 8mm,字号降到 10 像素,行高压缩到 1.3。表头用position: static,因为打印时sticky没有意义,反而可能在分页处重复。背景色去掉,改成纯黑白边框,省墨也清楚。
@page { size: A4 landscape; margin: 10mm; } table { font-size: 10px; line-height: 1.3; column-count: 2; column-gap: 8mm; } th { position: static; background: none; border-bottom: 1.5px solid #000; }有一点需要实测确认:不同浏览器的分栏算法不一样,有的会在表格中间硬切,导致一行被拆到两栏。稳妥的做法是把表格拆成两个独立的table,前 64 行一个,后 64 行一个,并排放进一个两列的容器里。这样分页位置完全可控,不会出现半行。
5. 踩过的坑与排查清单
5.1 中文列对不齐的三种情况
中英文混排对齐是这类脚本最容易翻车的地方,我遇到过三种情况,原因各不相同。
第一种是str.ljust直接补空格,中文列整体右移。原因是汉字在终端占两列,但len()只算一个字符。解决办法就是前面那个disp_width函数。第二种是用了disp_width之后仍然轻微偏移,排查下来是标点符号的问题,中文全角标点的宽度是 2,但英文半角标点在某些字体下渲染宽度不是严格的 1。这种情况没法根治,只能尽量让表格里不出现混合标点。第三种是在代码编辑器里对齐了,贴到终端里却乱了,因为编辑器用的是等宽字体,而终端可能用的是非等宽字体。判断方法很简单:看输出里MMMM和iiii是否等宽,如果不是,说明字体不对,换一个等宽字体再试。
注意:
disp_width只对终端和纯文本对齐有效,HTML 里不要用它来补空格,浏览器有自己的排版规则,补空格反而会撑开单元格。HTML 的对齐交给 CSS 的padding和text-align。
5.2 CSV 打开后中文乱码
这个问题我踩过不止一次。用utf-8编码写 CSV,某些电子表格软件打开时会把中文识别成乱码,原因是它默认按本地编码解析,不认没有 BOM 的 UTF-8。解决办法是写文件时用utf-8-sig,Python 会自动在文件头加上 BOM 标记。
Path("ascii.csv").write_text(text, encoding="utf-8-sig")注意只有 CSV 需要这么做。JSON 加 BOM 反而会导致解析失败,Markdown 加了也不会有什么好处,HTML 里如果已经有<meta charset="utf-8">,BOM 是多余的。所以代码里用的是条件判断,只对 CSV 用utf-8-sig。
还有一种情况是文件本身没问题,但某些软件把长数字列自动转成了科学计数法,比如把01000001当成数字处理。这属于软件的行为,不是脚本的问题。规避办法是在二进制列前面加一个前导单引号,或者在生成 CSV 时把该列强制加引号。我倾向于不处理,因为加引号会影响其他工具解析,需要的时候在表格软件里手动设成文本格式更干净。
5.3 控制字符把终端搞乱
有一次我图省事,直接把chr(code)打印到终端查看控制字符,结果终端进入了奇怪的图形模式,光标不见了,敲命令也没反应。原因很简单:chr(27)是 ESC,后面跟着的字符可能被终端解释成控制序列;chr(7)会让终端响铃;chr(13)会让光标回到行首覆盖已有内容。
这类问题在生成表的时候必须提前规避。我的做法是控制字符一律用脱字符表示(^@、^A这种),绝对不直接输出原始字符。可打印字符里也有一小部分需要小心,比如 127 和 32,前者用^?,后者用(空格)这种文字替代。
提示:如果不小心把终端搞乱了,先试试输入
reset回车。如果连回显都没有,盲打reset再回车通常也能恢复。实在不行就关掉终端重开。
5.4 常见问题速查表
把上面这些问题和另外几个高频问题整理成一张表,遇到时可以直接对号入座。
| 现象 | 可能原因 | 排查方法 | 处理办法 |
|---|---|---|---|
| 中文列整体右移 | 用len()而非显示宽度 | 对比含中文行和纯英文行 | 改用disp_width计算宽度 |
| CSV 中文乱码 | 编码为无 BOM 的 UTF-8 | 用十六进制工具看文件头 | 写文件时用utf-8-sig |
| 终端显示错乱 | 直接输出了控制字符 | 看是否有 ESC 或 BEL | 控制字符一律用脱字符表示 |
| Markdown 表格错位 | 内容含未转义的竖线 | 搜索表格里的 ` | ` |
| HTML 表格撑破 | 内容含<或& | 检查 60、38 号字符所在 |