打字机式敲完最后一行日志,我把手里那台开了 Windows 自带计算器的笔记本合上了。那天在核对一份设备通信日志,报文字段是一串03 1A FF E0,需要把后两字节当成无符号 16 位数拼出来,再和掩码做与运算。系统计算器的程序员模式其实能算,但每换一次位宽就得重新点一遍,算完一关窗口,刚才那串表达式就再也找不回来了。那天晚上我索性用 C# 写了个属于自己的十六进制计算器:能解析带0x前缀的表达式、能一次算完0xFF & (0x1F << 3)这种混合运算、能在 8/16/32/64 位视图之间一键切换。这篇就把整个设计与实现过程拆开讲,包括我踩过的几个坑。适合有一定 C# 基础、平时要和寄存器、报文、颜色值、内存地址打交道的读者,也适合想拿一个小项目练手表达式解析的人。
1. 从一次协议联调说起:为什么我会需要一个十六进制计算器
1.1 手算十六进制的四类高频崩溃场景
干这行久了会发现,十六进制运算出错的地方高度集中,翻来覆去就那么几类。第一类是报文字段拼接,高字节和低字节要合成一个 16 位值,0x1A和0xE0拼起来是0x1AE0而不是0x1AE0加错了位。第二类是寄存器位域操作,某个字段占 bit 4 到 bit 7,就得先构造掩码0x0F << 4,再做与和移位,中间任何一步搞错,读出来的配置值就是错的。第三类是内存地址偏移,基址0x1A00加上偏移0x2C,心算经常进位进错。第四类是颜色值拆解,0xFF3366CC拆成 A、R、G、B 四个分量,或者反过来拼装。
这四类场景的共同点是:参与运算的数都不大,但进制转换和位宽概念交织在一起,人脑在这种混合任务上极不可靠。我自己就有过把0x0F当成十进制 15 之外的某个值、结果把掩码写错导致一整批配置没生效的经历。从那以后我就认定,这类活儿必须交给工具,而且是一个理解十六进制语义的工具,不是那种只会做十进制加减法的工具。
提示:如果你也经常在日志里看到成串的十六进制字节,建议先把"字节序"和"位宽"两个概念固定下来再动手,工具只是加速,判断还得靠人。
1.2 系统计算器覆盖不到的三个缺口
系统自带的程序员模式确实是好东西,能切换 HEX/DEC/BIN,能做位运算。但我用下来的三个缺口始终绕不过去:
| 需求维度 | 系统计算器 | 我实际需要的能力 |
|---|---|---|
| 表达式留存 | 算完即走,历史记录弱 | 保留表达式原文,能回看、能修改后重算 |
| 位宽视图 | 需要手动切换单选按钮 | 一次运算同时看到 8/16/32/64 位四种呈现 |
| 有符号解释 | 有,但和位宽绑定较紧 | 同一个值在无符号与补码之间快速对照 |
| 脚本化/批量 | 基本没有 | 能把一组表达式丢进去批量出结果 |
最关键的是第一条。调协议的时候,同一个表达式我往往要改其中一两个常数反复试,系统计算器每改一次都要重新敲一遍全部内容,改到第五遍的时候人已经开始烦了。而"批量算一组"这个需求,在核对一张寄存器配置表时特别强烈——表里几十个字段的掩码都要算,一个个点太慢。
1.3 第一版的功能边界:先砍掉什么
动手前我先划了功能边界,这一步比写代码重要。小工具最容易死在"什么都想做"上,做着做着就变成了一个半成品 IDE。
| 功能 | 优先级 | 实现方式 |
|---|---|---|
| 四则运算 + 位运算 | P0 | 自建表达式解析器 |
0x/0b/ 十进制混输 | P0 | 词法分析阶段识别前缀 |
| 8/16/32/64 位十六进制视图 | P0 | 掩码 + 补码渲染 |
| 有符号/无符号切换 | P1 | 视图层开关,不影响内部值 |
| 表达式历史 | P1 | 内存列表 + 可选落盘 |
| 超过 64 位的大数 | P1 | 内部统一用 BigInteger |
| 变量定义、宏、函数 | P2 | 明确不做,避免复杂度失控 |
砍掉的东西里,最舍不得的是"变量定义",本来想支持mask = 0x0F << 4然后后续引用。后来想清楚了,我的使用场景是"算一个数",不是"写一段程序",变量定义带来的状态管理成本远大于收益。这个取舍在后面的实现里被反复验证是对的——没有状态,求值函数就是纯函数,测试起来非常省心。
2. 数值基座:C# 里到底该用哪种类型承载十六进制数
2.1 五种候选类型的实战对照
C# 里能装整数的类型不少,但真要承载"用户随便敲进来的十六进制串",能用的其实没几个。我列了张表逐个过了一遍:
| 类型 | 位宽 | 支持位运算 | 越界行为 | 是否适合本项目 |
|---|---|---|---|---|
int | 32 | 是 | 解析时抛OverflowException | 不够,0xFFFFFFFF直接炸 |
uint | 32 | 是 | 同上,只是上限高一点 | 不够,64 位场景直接放弃 |
long | 64 | 是 | 同上 | 勉强,但负数与无符号切换别扭 |
decimal | 128 | 否 | 溢出抛异常 | 直接排除,不支持位运算 |
BigInteger | 任意 | 是(现代 .NET 已补齐) | 不溢出 | 选它 |
decimal是最先被排除的,它精度高但不支持&、|、<<这些运算符,用它做十六进制运算等于自断手脚。int和uint的问题在于位宽写死,用户输入一个 64 位地址就崩了。long看起来够用,但它在处理"同一个 64 位值,既要当无符号看又要当补码看"的时候,代码里到处是unchecked和强制转换,可读性很差。
2.2 为什么把 BigInteger 定为唯一内部表示
最终我把内部表示统一成BigInteger,所有运算都在这个类型上完成,只在显示阶段才考虑位宽。这个决定的理由有三条。
第一,符合人的思维顺序。人在纸上算0xFF & (0x1F << 3)的时候,不会先想"这是 8 位还是 32 位",而是先把表达式算出来,最后再看结果落在什么位宽里。如果内部用定宽类型,运算过程中就要不断处理溢出和符号扩展,等于把复杂度提前了。
第二,规避了新手最容易栽的那个异常。int.Parse("FFFFFFFF", NumberStyles.HexNumber)会抛OverflowException,因为0xFFFFFFFF超过了int.MaxValue。这个坑我自己踩过,当时百思不得其解,明明字符串是合法的十六进制。换成BigInteger.Parse就没这个问题,它有多少位就装多少位。
第三,大数场景白送。某些场景下会遇到 128 位甚至更长的标识符,用BigInteger天然支持,不用改一行代码。
代价当然也有:BigInteger是引用类型(结构体包装),每次运算都会分配,性能比原生类型差一到两个数量级。但这个计算器的输入是人工敲的,一次求值撑死几百次运算,性能完全不是瓶颈。我在实测中给一个 200 字符的长表达式计时,单次求值在微秒级,键盘输入根本感知不到。
2.3 内部值与显示视图的彻底分离
既然内部不定宽,那"位宽"这个概念就只存在于显示层。我把它抽象成一个渲染函数,输入是内部值和位宽,输出是定长的十六进制字符串:
public static string ToHexView(BigInteger value, int bits) { if (bits % 4 != 0 || bits <= 0) throw new ArgumentOutOfRangeException(nameof(bits)); // 构造该位宽的全 1 掩码,注意必须用 BigInteger.One 左移 BigInteger mask = (BigInteger.One << bits) - 1; // 按补码语义取低位;BigInteger 内部就是补码表示,& 之后自然得到正确结果 BigInteger low = value & mask; int digits = bits / 4; return low.ToString("X").PadLeft(digits, '0'); }这段代码里有两个细节值得说。一是掩码的构造必须写BigInteger.One << bits,如果图省事写成(1 << bits) - 1,当bits等于 64 时1 << 64在int语义下会回绕成1(因为 C# 的移位量会按位宽取模),掩码直接错成 0,结果全是零。这个坑非常隐蔽,因为它在 32 位及以下都正常,只有到 64 位才暴露。
二是value & mask这一句已经完成了补码转换。BigInteger的位运算遵循二进制补码语义,-4 & 0xFFFFFFFF会得到4294967292,也就是0xFFFFFFFC。不需要自己写"如果是负数就加上 2 的 n 次方"这种逻辑,写了反而容易出错。
2.4 解析入口的三个脏活
BigInteger.Parse虽然好用,但它不认识0x前缀,也不认识分隔符。所以真正的解析入口得自己写一层:
public static bool TryParseNumber(string text, out BigInteger value, out string error) { value = BigInteger.Zero; error = null; if (string.IsNullOrWhiteSpace(text)) { error = "空的数字"; return false; } string s = text.Trim().Replace("_", ""); // 允许 0xFF_FF 这种分隔写法 try { if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { value = BigInteger.Parse(s.Substring(2), NumberStyles.HexNumber); return true; } if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) { string digits = s.Substring(2); BigInteger acc = BigInteger.Zero; foreach (char c in digits) { if (c != '0' && c != '1') { error = "二进制里出现了非 0/1 字符"; return false; } acc = (acc << 1) + (c - '0'); } value = acc; return true; } value = BigInteger.Parse(s, NumberStyles.Integer); return true; } catch (FormatException) { error = "数字格式不合法"; return false; } }这里我踩过一个细节:BigInteger.Parse("-FF", NumberStyles.HexNumber)是能成功的,返回-255,因为NumberStyles.HexNumber包含了AllowLeadingSign。这意味着如果你不小心把负号和十六进制混在一起,它不会报错,而是悄悄给你一个负数。我在词法分析阶段把负号单独当成一元运算符处理,就是为了避开这个模糊地带——负数只能由-运算符产生,不能由数字字面量自带。
注意:不要依赖
NumberStyles.HexNumber对负号的处理,不同版本运行时的行为细节可能有差异,把符号交给解析器统一管理更稳。
3. 表达式引擎:把0xFF & (0x1F << 3)变成结果的那套流程
3.1 分两步走:先切词,再递归下降
朴素的做法是对字符串做正则匹配然后从左往右算,但那样处理不了括号,也处理不了优先级。正规做法是分成词法分析和语法分析两步。
词法分析负责把连续的字符流切成有意义的记号(Token),比如把0xFF切成一个数字记号,把<<切成一个移位记号。语法分析负责按优先级把这些记号组织成一棵隐式的树。分成两步的好处是错误定位非常准——词法阶段发现非法字符,能直接报出"第 7 个字符无法识别";语法阶段发现括号不匹配,能报出"第 12 个位置缺少右括号"。如果混在一起写,报错信息通常只能是"表达式有误",用户看了等于没看。
3.2 运算符优先级表:必须按 C 的语义来
优先级这件事不能凭感觉定,否则用户按 C 语言的习惯写表达式就会得到意外结果。我严格照搬了 C 家族的优先级:
| 优先级(从高到低) | 运算符 | 结合性 | 说明 |
|---|---|---|---|
| 1 | ~-(一元) | 右 | 按位取反、取负 |
| 2 | */% | 左 | 乘除取模 |
| 3 | +- | 左 | 加减 |
| 4 | <<>> | 左 | 移位 |
| 5 | & | 左 | 按位与 |
| 6 | ^ | 左 | 按位异或 |
| 7 | | | 左 | 按位或 |
最容易搞错的是移位的层级。很多人凭直觉觉得移位应该和乘除同级(毕竟左移一位等于乘二),但在 C 里移位低于加减高于与。这个差异在0xFF & 0x0F << 4这种表达式上会体现得非常明显:按 C 语义先算0x0F << 4得0xF0,再与0xFF得0xF0;如果移位和乘法同级,结果也一样;但如果有人把移位写得比加法还高,1 + 1 << 2就会从(1+1)<<2 = 8变成1 + (1<<2) = 5。我第一版就把移位放错了层,和系统计算器对答案时差了 8 倍,查了半天。
3.3 词法分析器:四十行把字符串拆成记号
先定义记号类型。用一个枚举加一个只读结构体就够了,不需要继承体系:
public enum TokenKind { Number, Plus, Minus, Star, Slash, Percent, Amp, Pipe, Caret, Tilde, Shl, Shr, LParen, RParen, End } public readonly struct Token { public readonly TokenKind Kind; public readonly BigInteger Value; public readonly int Position; public Token(TokenKind kind, BigInteger value, int position) { Kind = kind; Value = value; Position = position; } }有了记号定义,切词逻辑就是一次线性扫描。注意这里的Position记的是记号在原串里的起始下标,专门用于报错:
public static List<Token> Tokenize(string src) { var tokens = new List<Token>(); int i = 0; while (i < src.Length) { char c = src[i]; if (char.IsWhiteSpace(c)) { i++; continue; } // 十六进制 if (c == '0' && i + 1 < src.Length && (src[i + 1] == 'x' || src[i + 1] == 'X')) { int start = i; i += 2; int begin = i; while (i < src.Length && (Uri.IsHexDigit(src[i]) || src[i] == '_')) i++; if (i == begin) throw new CalcException(start, "0x 后面缺少十六进制数字"); string digits = src.Substring(begin, i - begin).Replace("_", ""); tokens.Add(new Token(TokenKind.Number, BigInteger.Parse(digits, NumberStyles.HexNumber), start)); continue; } // 二进制 if (c == '0' && i + 1 < src.Length && (src[i + 1] == 'b' || src[i + 1] == 'B')) { int start = i; i += 2; int begin = i; while (i < src.Length && (src[i] == '0' || src[i] == '1' || src[i] == '_')) i++; if (i == begin) throw new CalcException(start, "0b 后面缺少二进制数字"); string digits = src.Substring(begin, i - begin).Replace("_", ""); BigInteger acc = BigInteger.Zero; foreach (char d in digits) acc = (acc << 1) + (d - '0'); tokens.Add(new Token(TokenKind.Number, acc, start)); continue; } // 十进制 if (char.IsDigit(c)) { int start = i; while (i < src.Length && (char.IsDigit(src[i]) || src[i] == '_')) i++; string digits = src.Substring(start, i - start).Replace("_", ""); tokens.Add(new Token(TokenKind.Number, BigInteger.Parse(digits), start)); continue; } switch (c) { case '+': tokens.Add(new Token(TokenKind.Plus, 0, i)); i++; continue; case '-': tokens.Add(new Token(TokenKind.Minus, 0, i)); i++; continue; case '*': tokens.Add(new Token(TokenKind.Star, 0, i)); i++; continue; case '/': tokens.Add(new Token(TokenKind.Slash, 0, i)); i++; continue; case '%': tokens.Add(new Token(TokenKind.Percent, 0, i)); i++; continue; case '&': tokens.Add(new Token(TokenKind.Amp, 0, i)); i++; continue; case '|': tokens.Add(new Token(TokenKind.Pipe, 0, i)); i++; continue; case '^': tokens.Add(new Token(TokenKind.Caret, 0, i)); i++; continue; case '~': tokens.Add(new Token(TokenKind.Tilde, 0, i)); i++; continue; case '(': tokens.Add(new Token(TokenKind.LParen, 0, i)); i++; continue; case ')': tokens.Add(new Token(TokenKind.RParen, 0, i)); i++; continue; case '<': if (i + 1 < src.Length && src[i + 1] == '<') { tokens.Add(new Token(TokenKind.Shl, 0, i)); i += 2; continue; } throw new CalcException(i, "单个 '<' 不是合法运算符,左移请写 <<"); case '>': if (i + 1 < src.Length && src[i + 1] == '>') { tokens.Add(new Token(TokenKind.Shr, 0, i)); i += 2; continue; } throw new CalcException(i, "单个 '>' 不是合法运算符,右移请写 >>"); default: throw new CalcException(i, $"无法识别的字符 '{c}'"); } } tokens.Add(new Token(TokenKind.End, 0, src.Length)); return tokens; }这段代码里Uri.IsHexDigit是个很好用的小工具方法,比自己写(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')干净得多,而且大小写都覆盖。另外注意我在每个分支末尾都用了continue而不是break,因为break会把控制权交给switch之后的代码,而continue直接回到while顶部开始下一轮,逻辑更干脆。
3.4 递归下降求值:每层函数只做一件事
语法分析我用递归下降,理由是优先级表改起来直观、报错位置精确、代码读起来像伪代码。每一层函数对应一个优先级,函数体只关心自己这一层的运算符,遇到更高优先级的调用下一层:
public sealed class Parser { private readonly List<Token> _tokens; private int _pos; public Parser(List<Token> tokens) { _tokens = tokens; _pos = 0; } private Token Current => _tokens[_pos]; private bool Match(TokenKind kind) { if (Current.Kind != kind) return false; _pos++; return true; } public BigInteger Evaluate() { BigInteger value = ParseBitOr(); if (Current.Kind != TokenKind.End) throw new CalcException(Current.Position, "表达式末尾有多余内容"); return value; } private BigInteger ParseBitOr() { BigInteger left = ParseBitXor(); while (Match(TokenKind.Pipe)) left |= ParseBitXor(); return left; } private BigInteger ParseBitXor() { BigInteger left = ParseBitAnd(); while (Match(TokenKind.Caret)) left ^= ParseBitAnd(); return left; } private BigInteger ParseBitAnd() { BigInteger left = ParseShift(); while (Match(TokenKind.Amp)) left &= ParseShift(); return left; } private BigInteger ParseShift() { BigInteger left = ParseAdditive(); while (true) { if (Current.Kind == TokenKind.Shl) { int pos = Current.Position; _pos++; left = ShiftLeft(left, ParseAdditive(), pos); } else if (Current.Kind == TokenKind.Shr) { int pos = Current.Position; _pos++; left = ShiftRight(left, ParseAdditive(), pos); } else return left; } } private BigInteger ParseAdditive() { BigInteger left = ParseMultiplicative(); while (true) { if (Match(TokenKind.Plus)) left += ParseMultiplicative(); else if (Match(TokenKind.Minus)) left -= ParseMultiplicative(); else return left; } } private BigInteger ParseMultiplicative() { BigInteger left = ParseUnary(); while (true) { if (Match(TokenKind.Star)) left *= ParseUnary(); else if (Match(TokenKind.Slash)) left = DivideSafely(left, ParseUnary(), Current.Position); else if (Match(TokenKind.Percent)) left = ModSafely(left, ParseUnary(), Current.Position); else return left; } } private BigInteger ParseUnary() { if (Match(TokenKind.Minus)) return -ParseUnary(); if (Match(TokenKind.Tilde)) return ~ParseUnary(); return ParsePrimary(); } private BigInteger ParsePrimary() { if (Match(TokenKind.LParen)) { BigInteger inner = ParseBitOr(); if (!Match(TokenKind.RParen)) throw new CalcException(Current.Position, "缺少右括号"); return inner; } if (Current.Kind == TokenKind.Number) { BigInteger v = Current.Value; _pos++; return v; } throw new CalcException(Current.Position, "这里应该是一个数字或左括号"); } }这种写法的好处是"优先级即代码结构"。哪天想把移位提到加法之前,只需要把ParseShift挪个位置,不用碰优先级表。缺点是对左结合的一元运算符处理起来要小心,比如--5会被解析成-(-5),这个行为是对的,但如果用户输入~~0xF0,也要能正确得到0xF0。
3.5 错误提示带上位置,比什么都有用
我一开始的报错信息就是一句"表达式语法错误",自己调试的时候都嫌烦,更别说用的时候。后来改成统一抛一个带位置的异常:
public sealed class CalcException : Exception { public int Position { get; } public CalcException(int position, string message) : base(message) { Position = position; } }界面层捕获这个异常后,把Position对应的字符高亮出来,再在状态栏显示消息。这个改动看起来微不足道,实际使用体验提升巨大——用户敲了一个长表达式,中间某个括号漏了,高亮直接指到出问题的位置,不用自己数括号。
4. 补码、溢出与负数显示:最容易翻车的三件事
4.1 负数在十六进制里为什么是一串 F
这是新手最常问的问题:我输入-4,为什么显示出来是FFFFFFFC?答案是补码。计算机用补码表示负数,-4等价于"取 4 的二进制,全部取反,再加一"。在 32 位下就是0xFFFFFFFC。同一个-4在不同位宽下的表示完全不同:
| 数值 | 8 位视图 | 16 位视图 | 32 位视图 | 64 位视图 |
|---|---|---|---|---|
| -1 | FF | FFFF | FFFFFFFF | FFFFFFFFFFFFFFFF |
| -4 | FC | FFFC | FFFFFFFC | FFFFFFFFFFFFFFFC |
| -256 | 00(截断) | FF00 | FFFFFF00 | FFFFFFFFFFFFFF00 |
| 255 | FF | 00FF | 000000FF | 00000000000000FF |
注意-256在 8 位视图下变成了00,因为它超出了 8 位有符号数的范围,低位全被截掉了。这个行为本身是正确的(截断就是截断),但如果用户不理解,就会觉得"计算器算错了"。所以我在界面上加了一行小字提示:当前显示的是低 N 位,值可能被截断。
4.2 位宽截断的正确写法
上一章给的ToHexView已经处理了截断,但还有个容易忽略的地方:掩码必须用 BigInteger 构造。我在第 2.3 节提过一次,这里再强调一遍,因为它值得单独成段。1 << 64在 C# 里会变成1(int 的移位量取低 5 位),1L << 64会变成1(long 取低 6 位)。这是语言规范规定的行为,不是 bug,但对写工具的人来说是个天坑,因为它在小位宽上完全正常。
还有一点要注意:显示时最好区分"截断后的无符号值"和"截断后的有符号解释"。前者直接& mask就行,后者还要判断最高位是否为 1:
public static BigInteger ToSigned(BigInteger value, int bits) { BigInteger mask = (BigInteger.One << bits) - 1; BigInteger low = value & mask; BigInteger signBit = BigInteger.One << (bits - 1); // 最高位为 1 表示负数,减去 2 的 bits 次方得到补码真值 return (low & signBit) != 0 ? low - (BigInteger.One << bits) : low; }这个函数在核对有符号传感器读数时特别有用。曾经有一份温度数据,原始字节是0xF0,按无符号看是 240,按 8 位补码看是 -16,实际就是 -16 摄氏度。如果没有这个切换,很容易把负温度当成高温。
4.3 除法与取模的符号陷阱
C# 的整数除法采用"向零取整",和数学上的"向下取整"不是一回事。这个差异在负数上会暴露得很明显:
| 表达式 | C# 结果 | 数学向下取整结果 | 差异说明 |
|---|---|---|---|
7 / 2 | 3 | 3 | 一致 |
-7 / 2 | -3 | -4 | 向零 vs 向下 |
7 % 2 | 1 | 1 | 一致 |
-7 % 2 | -1 | 1 | 余数符号跟随被除数 |
如果你是从别的语言(比如某些脚本语言)转过来的,很可能会在这里翻车。我在求值器里没有去改这个行为,而是保持和 C# 一致,因为用户多半也是写 C# 或 C 的,保持语义一致比"更符合数学直觉"更重要。但我加了一条规则:除数为零时抛异常,不做静默处理。
private BigInteger DivideSafely(BigInteger left, BigInteger right, int pos) { if (right.IsZero) throw new CalcException(pos, "除数不能为零"); return left / right; } private BigInteger ModSafely(BigInteger left, BigInteger right, int pos) { if (right.IsZero) throw new CalcException(pos, "取模运算的除数不能为零"); return left % right; }静默返回 0 是最糟糕的选择,用户拿着一个 0 去继续算,最后得到一堆莫名其妙的结果,排查成本极高。宁可当场炸掉。
4.4 移位:原生类型会偷偷取模移位量
这是我在对比测试时发现的一个诡异现象:同一个表达式1 << 32,用int算得到 1,用BigInteger算得到 4294967296。原因是 C# 规范规定,移位运算的移位量会按左操作数的位宽取模——int取低 5 位,所以 32 变成 0,1 << 0等于 1。
BigInteger没有位宽概念,所以移位量是多少就移多少位,结果是正确的。但这个不一致提醒我,在求值器里要主动检查移位量,避免用户写出1 << 100000这种把内存吃爆的表达式:
private static readonly int MaxShift = 4096; private BigInteger ShiftLeft(BigInteger value, BigInteger amount, int pos) { if (amount.Sign < 0) throw new CalcException(pos, "移位次数不能为负数"); if (amount > MaxShift) throw new CalcException(pos, $"移位次数超过上限 {MaxShift}"); return value << (int)amount; } private BigInteger ShiftRight(BigInteger value, BigInteger amount, int pos) { if (amount.Sign < 0) throw new CalcException(pos, "移位次数不能为负数"); if (amount > MaxShift) throw new CalcException(pos, $"移位次数超过上限 {MaxShift}"); return value >> (int)amount; }BigInteger的>>对负数是算术右移,会保留符号位,也就是-8 >> 1 == -4。这和大多数语言的语义一致,符合预期。如果你需要逻辑右移(高位补零),得自己先转成无符号视图再移:
// 逻辑右移:先把值当成指定位宽的无符号数,再右移 public static BigInteger LogicalShiftRight(BigInteger value, int bits, BigInteger amount) { BigInteger mask = (BigInteger.One << bits) - 1; return (value & mask) >> (int)amount; }这个区别在处理 CRC 校验、哈希算法的时候至关重要,算术右移会把高位全填成 1,结果彻底错掉。我在界面上加了一个"逻辑右移"的开关,默认关闭,需要的时候手动打开。
5. 三种壳子的取舍:控制台、WinForms 与 WPF 分别适合谁
5.1 控制台版:验证引擎的最短路径
引擎写完后,我第一件事是包了个控制台读循环,二十行代码:
static void Main() { Console.WriteLine("输入表达式,空行退出。"); while (true) { Console.Write("> "); string line = Console.ReadLine(); if (string.IsNullOrWhiteSpace(line)) break; try { var tokens = Tokenize(line); var parser = new Parser(tokens); BigInteger result = parser.Evaluate(); Console.WriteLine($" HEX32: {ToHexView(result, 32)}"); Console.WriteLine($" DEC : {result}"); Console.WriteLine($" BIN : {ToBinaryView(result, 32)}"); } catch (CalcException ex) { Console.WriteLine($" 错误(位置 {ex.Position}):{ex.Message}"); } } }控制台版的价值不在于"能用",而在于调试反馈最快。改一行引擎代码,dotnet run一秒出结果,比在图形界面里点按钮快得多。我整个引擎的开发和调试都是在这个壳里完成的,图形界面是最后两天才加上去的。这个顺序建议所有人都照着走——先把核心逻辑在一个能快速迭代的环境里打磨透,再做界面。反过来的话,你会发现每改一次算法都要启动一次窗口程序,效率低得让人想放弃。
5.2 WinForms 版:半小时出可用界面
桌面小工具,WinForms 是最省事的。不需要学 XAML,拖控件、绑事件,半小时能出一个能用的东西。我用TableLayoutPanel生成按钮网格:
private readonly string[] KeyLabels = { "A", "B", "C", "D", "E", "F", "7", "8", "9", "&", "|", "~", "4", "5", "6", "<<", ">>", "^", "1", "2", "3", "+", "-", "%", "0", "0x", "(", ")", "*", "/", "CE", "DEL", "=", "" }; private void BuildKeypad() { var grid = new TableLayoutPanel { ColumnCount = 6, Dock = DockStyle.Fill }; for (int c = 0; c < 6; c++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / 6)); int rows = (KeyLabels.Length + 5) / 6; for (int r = 0; r < rows; r++) grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / rows)); for (int i = 0; i < KeyLabels.Length; i++) { if (string.IsNullOrEmpty(KeyLabels[i])) continue; var btn = new Button { Text = KeyLabels[i], Dock = DockStyle.Fill, Tag = KeyLabels[i] }; btn.Click += KeyButton_Click; grid.Controls.Add(btn, i % 6, i / 6); } Controls.Add(grid); }按钮点击统一走一个入口,根据Tag决定是插入字符还是执行命令。这种"数据驱动"的写法比一个个button1_Click好维护得多,以后加按键只需要改数组。
private void KeyButton_Click(object sender, EventArgs e) { string key = (string)((Button)sender).Tag; switch (key) { case "=": EvaluateAndShow(); break; case "CE": _expression = ""; RefreshDisplay(); break; case "DEL": if (_expression.Length > 0) _expression = _expression.Substring(0, _expression.Length - 1); RefreshDisplay(); break; default: _expression += key; RefreshDisplay(); break; } }5.3 WPF 版:实时求值体验最好
WPF 的优势在于数据绑定。把表达式绑到TextBox,结果绑到几个只读属性,每次输入变化自动重算,不需要手动刷新。ViewModel 大概长这样:
public class CalcViewModel : INotifyPropertyChanged { private string _expression = ""; public string Expression { get => _expression; set { _expression = value; OnPropertyChanged(); Recalculate(); } } public string Hex8 { get; private set; } = ""; public string Hex16 { get; private set; } = ""; public string Hex32 { get; private set; } = ""; public string Dec { get; private set; } = ""; public string Error { get; private set; } = ""; private void Recalculate() { try { var tokens = Tokenize(_expression); BigInteger value = new Parser(tokens).Evaluate(); Hex8 = ToHexView(value, 8); Hex16 = ToHexView(value, 16); Hex32 = ToHexView(value, 32);