分类: 技术文章

经过整理的原理说明、源码分析与专题文章。

  • 机器学习为什么能从数据中形成可泛化的模型

    作者:lihao技术文章发布:AI 原理与智能系统 · 第 02 期

    用同一组点比较记忆表与线性规则,再用改变分布的反例说明:训练误差很小,仍然不足以保证未来表现。

    训练集做对了,为什么还不够

    假设训练数据只有三个点:(0,1)、(1,3)、(2,5)。规则f(x)=2x+1能全部答对;记忆表也能全部答对,只要遇到表外输入就输出0。两者训练误差都为零,在x=3时却分别给出7和0。

    机器学习的困难因此不只是找到一个能解释已有数据的函数,而是在有限观察之外仍作出有用预测。泛化指模型在目标分布的新样本上的表现;它不是“记得更多”,也不是凭训练集本身就能无条件保证的能力。

    下面固定一个一维、无噪声、合成数据的例子,只比较规则。这些点是合成数据,目的在于隔离模型偏好的作用。

    数据之外,还需要一种偏好

    从三个点出发,有无穷多函数都能穿过它们。选择线性函数,等于事先限制模型族:我们愿意优先考虑直线。这个限制是一种归纳偏置,训练只能在它允许的候选中寻找参数。

    令预测为ax+b,用训练点的平方误差之和作目标。由(0,1)得到b=1,再由(1,3)得到a=2,第三点与该规则一致。对这组无噪声数据,参数可以直接推导,不必调用复杂库。

    这个结果为什么可能有效?如果未来数据也遵循同一规律,所选模型族包含了它,训练又确定了正确参数,那么在新点上也能答对。任何一项条件变化,结论都需要重新检查。教材中的泛化理论正是在明确样本、假设空间、损失与分布条件后讨论保证。[1]

    用保留点检验,再用反例检查条件

    固定训练输入0、1、2,保留测试输入3、4,目标仍为2x+1。线性规则预测7、9,平均平方误差为0;上述记忆规则预测0、0,平均平方误差为(49+81)/2=65。

    这里有意采用区间外的保留点,是一次外推压力测试,不是独立同分布抽样的统计评估。它能显示两条规则的不同,不能估计真实应用中的平均误差。若把这些测试点拿来选模型,它们随后就成了模型选择依据,还应另留最终评估数据。

    再构造另一种世界:x≤2时y=2x+1,x>2时y=100。训练数据完全不变,但线性规则在3、4上误差变成93和91,平均平方误差为8465。这不是算法突然算错,而是训练样本没有区分两种未来规律。仅凭前面三个点,无法排除后一种世界。

    实际评估如何避免自己骗自己

    先按数据产生过程划分训练、验证与测试。训练集用于拟合;验证集用于选择模型或超参数;测试集用于相对独立的最终评价。若同一人、同一设备或同一段时间的高度相关样本分落两边,就可能出现泄漏。

    例如,用一位用户的多条近乎重复消息同时训练和测试,可能主要测到了记忆能力。若任务是预测未来,把未来记录中的特征泄漏到过去样本,同样会高估效果。具体划分应服从应用中的独立单位与时间关系,不能机械地随机打散。

    正则化、早停或限制模型复杂度,是调节偏好的方法,不是免检证书。数据有偏、标签错误、部署分布变化,都可能让训练过程正常完成而模型失效。

    把经验误差与目标风险分别写清

    设训练集S={(xᵢ,yᵢ)},候选模型为f,损失为ℓ。经验风险R̂ₛ(f)是训练样本损失的平均;目标风险Rᴅ(f)是在部署相关分布D上损失的期望。训练直接操作的是前者,用户最终关心的通常是后者。

    两者差异可以分成两类问题。第一,即使把训练目标降到很低,有限样本仍可能不代表D;第二,模型族本身可能不包含低风险函数。前者涉及估计和选择,后者涉及近似能力。优化器没收敛又是额外一层,不能把所有误差都叫过拟合。

    在独立同分布、损失有界、候选集合有限等条件下,可以用集中不等式与对候选的联合控制得到一致的泛化界。具体地,若损失在[0,1]、假设集合H预先固定且有M个元素、n个样本独立同分布,则以至少1−δ的概率,对全部f∈H都有 |Rᴅ(f)−R̂ₛ(f)|≤√(ln(2M/δ)/(2n))。对经验风险最小化解,这进一步给出相对H中最优目标风险至多2倍该界的差距。它是充分界,可能很松;前面的平方损失例子若没有先界定输入和输出范围,也不能直接代入这个[0,1]损失界。[1,第4—6章] 这里不把这一有限模型结果原封不动外推到任意深网、相关数据或分布迁移。

    关键是“一致”控制:训练后选择的f依赖S,不能把只对预先固定f成立的估计,未经处理就用于反复选出来的最优模型。这也是验证集、独立测试和模型选择理论要处理的核心依赖。

    代入一个有限候选例子:在固定的 100 个模型中选择,n=1000、δ=0.05,则界为 √(ln4000/2000)≈0.0644。意思是:在前述抽样与损失条件成立时,以至少 95% 的概率,抽到的训练集会使全部 100 个模型的经验风险与目标风险相差不超过约 0.0644。这项概率针对训练集的抽样,不表示模型每次预测有 95% 的准确率。

    ERM 的超额风险保证相应不超过约 0.1288,可能远比实际误差宽。若只靠这个充分界要求一致差距不超过 0.01,至少需要 41471 个样本;这不是声称较少样本必定失败,而是说这条界尚不足以作出那样强的保证。[1,推论4.6]

    带一点噪声,再看规则怎样形成

    构造训练点(0,1)、(1,2)、(2,5),仍限定f(x)=ax+b,并最小化平方误差。三点不在一条直线上,不能同时零误差。均值x̄=1、ȳ=8/3,斜率a=Σ(xᵢ-x̄)(yᵢ-ȳ)/Σ(xᵢ-x̄)²=4/2=2,截距b=2/3。

    三次预测为2/3、8/3、14/3;残差分别为-1/3、2/3、-1/3,训练MSE为2/9。这个拟合在模型族和损失固定后是可核算的结果,既没有完全记住训练标签,也没有证明噪声来自何种现实过程。

    如果我们知道中间标签本应为3而记录成2,修正数据可能比增大模型更合理;如果标签2是可靠观察,则“真实关系一定是2x+1”的假设要受审查。学习不是让算法替研究者决定所有数据质量问题。

    正则化可以在误差目标中增加参数复杂度惩罚,例如λa²。它改变了我们偏好的解,而不是给原问题免费增加证据。λ越大通常越压制斜率,但是否降低目标风险仍要独立评估,不能只看目标函数值。

    不确定性如何进入结果,而不是只写一个分数

    两个保留点上的零误差,不等于风险已知为零。样本量小、输入范围窄或样本高度相关,都会使估计不稳定。若任务允许,应报告样本数量、抽样单位与误差分布;重复实验还需区分数据变化与训练随机性。

    分布变化也有不同形式:输入频率改变、标签机制改变、观测流程改变,所需应对并不相同。前文y在区间外变成100的反例,直接改变了关系机制;简单扩大缓存或提高训练轮数无法从相同三点恢复被遗漏的信息。

    真正的部署判断还要规定何时拒答、何时转人工、如何监测失效。泛化理论解释某些条件下为什么有限样本可能足够;工程评估说明这些条件和实际任务相差多远。两者结合,才比一句“数据越多越聪明”更有解释力。

    捷径学习:样本很多,也可能学到错误的依赖

    构造一个分类数据集,真实目标与形状有关,但训练图片中圆形总是红底、方形总是蓝底。一个只看背景的模型可以在随机划分的测试集上很好,因为两边都保留了同样的关联。样本数量增加,只会让这条捷径更容易被确认。

    若部署时背景与形状独立,原指标就可能失效。问题不是模型不能拟合,而是评估分布没有检验真正需要的依赖。处理方式可以包括按来源分组、构造打破背景关联的保留集,或取得更能区分候选机制的数据;不能只靠把相同来源继续随机拆分。

    这也显示“归纳偏置”不总是坏事。一个适合任务的结构或数据策略,可以抑制某些捷径;但若预设错了,也会排除有用关系。应将假设写出,让反例有机会改变它。

    前面的泛化界把训练与目标分布关系作为条件,捷径例子则问这个条件是否描述了实际应用。统计保证与部署有效性因此需要一起阅读:公式严谨,不等于应用假设已经被数据验证。

    泛化不等于理解全部规律

    本例的线性规则确实比记忆表更适合第一种目标,但不能据此宣布简单模型永远更好。若真实关系是复杂非线性,过强的限制也会造成欠拟合;若样本量与任务结构支持,更丰富的模型可能有优势。

    最终应报告目标任务、输入范围、损失、划分方式、误差与失败样本。平均指标还可能掩盖某些群体或边界输入的失败。若任务要求超出已验证范围,就应明确标出证据缺口。

    从数据形成可泛化模型,靠的是数据、结构假设、训练方法和评估条件的共同作用。这个回答保留了一个必要限制:没有任何有限样本能单独替我们承诺任意未来世界。

    来源、证据与阅读路线

    1. Shalev-Shwartz、Ben-David,Understanding Machine Learning,2014,第2章学习模型及第5—6章归纳偏置与可学习性
    2. Goodfellow等,Deep Learning,2016,第5章5.2—5.3:泛化、过拟合、超参数与验证集

    证据范围:回归、含噪拟合及统计界的数值按已列条件复算;外推点是构造压力测试,有限假设类保证不等于现实数据满足抽样假设。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 人工智能、机器学习、深度学习:三个概念怎样区分

    作者:lihao技术文章发布:AI 原理与智能系统 · 第 01 期

    用同一个分类任务区分手写规则、从样本拟合和多层表示学习;模型的计算方式与系统职责分别讨论。

    三个词,回答的并不是同一层问题

    一套程序能下棋,我们会说它属于人工智能研究的对象;它是否必须先从大量棋谱学习?并不必须。若把人工智能、机器学习和深度学习当成同义词,就很难说清一套系统靠什么得到能力。

    本文采用常见的教学关系:人工智能(AI)是围绕智能任务的广泛研究领域;机器学习(ML)研究如何借助数据或经验改进任务表现,是实现AI的一类方法;深度学习(DL)是以多层可学习表示为重要特征的一类机器学习方法。这个包含关系便于定位方法,不是按商业产品名称划分的法律或标准分类。

    1955年的达特茅斯项目建议书把语言、抽象、问题求解与自我改进等列为研究问题。[1] 它提出的是研究设想,不能把当年的设想当作后来已经证明所有智能都可被机器完整模拟的结论。

    用一个任务比较三种做法

    设想给短消息分成“普通”与“待复核”两类。为了讲清机制,先只用两个特征:某个词出现的次数x₁,链接数量x₂。这是本文构造的教学任务,没有真实用户消息,也不评价任何真实检测系统。

    第一种做法是人工写规则:若链接超过某个阈值,就送去复核。阈值由人指定,没有从样本自动拟合;规则系统仍可能是更大AI系统的一部分,不能仅因没有神经网络就断言它与AI无关。

    第二种做法是固定线性打分形式s=w₁x₁+w₂x₂+b,再利用带标签样本选择w₁、w₂和b。人规定特征、模型族与损失,训练过程按数据调整参数。它属于机器学习,却不因参数是学来的就自动属于深度学习。

    第三种做法是让模型从字符或词元等输入出发,经过多层变换形成表示并输出分类。中间表示也参与学习,这是深度学习常见的工作方式。究竟多少层才叫深,并没有一个适用于所有架构的固定门槛。[2]

    学习发生在什么地方

    把训练过程写成“数据→训练算法→参数”,把使用过程写成“新输入+参数→输出”,就能区分训练与推理。一个已经训练好的模型,在每次推理时可以只是执行确定的运算;它不一定边回答边更新权重。

    因此,运行程序时行为很稳定,不妨碍它的参数来自学习;反过来,程序根据一条人工规则改变输出,也不等于它正在学习。判断时要找可观察的更新机制,而不是凭“看起来聪明”下结论。

    Mitchell的机器学习教材把学习与任务、经验和表现改进联系起来。[3] 实际报告还必须说明指标和数据划分,否则“模型学习了”可能只是记住训练样本。下一篇会专门讨论这个差别。

    一张表看清比较对象

    比较项 手写规则示例 线性学习示例 多层表示学习示例
    主要由人指定 规则及阈值 特征、模型族与目标 架构、目标与训练流程
    从样本调整 本例没有 权重和偏置 多层参数及表示
    必须核验 覆盖与例外 未见数据误差 未见数据误差与计算代价

    这张表不能推出第三种一定最好。样本少、解释要求强、任务规则明确时,简单规则或浅层模型可能更合适。在非线性、宽度和连接结构适当时,更深的模型可能更有效地表达某些函数;但深度本身不保证表达力或效果提升,也可能增加训练、调试与部署成本。它不会自动带来正确标签或稳定泛化。

    把一层网络写出来,才知道“学了表示”是什么意思

    令输入为二维向量x=(x₁,x₂)。构造隐藏表示h=ReLU(Wx+b),再计算分数s=vᵀh+c。ReLU把负数截为0,保留非负数。这里的关键不只是乘法数量多,而是前一级的输出成为后一级的输入,而且W、b、v、c可以共同由训练目标调整。

    若第一层只做线性变换h=Wx,后一层也只做线性变换s=vᵀh,那么复合后仍是s=(vᵀW)x。一味叠加线性层,并没有获得任意非线性关系。非线性、结构与目标之间的配合,才决定模型能表达什么。

    例如用两个二值输入构造XOR:输入00、11标0,01、10标1。假定单个线性阈值分类器s=w₁x₁+w₂x₂+b≥0时输出1,则00为0要求b<0;01、10为1分别要求w₂+b≥0和w₁+b≥0。相加得到w₁+w₂+2b≥0,因此11处的s≥-b>0,与其应为0矛盾。问题不在训练次数不够,而在这个模型族不能表达该划分。

    多层非线性模型可以构造相应区域,但这不是说每个实际分类问题都需要深网。XOR只是一项表达能力的例证,没有代表自然数据分布,也没有比较学习效率、鲁棒性或部署成本。[2,表示与复合函数的讨论]

    学习、搜索和推理,为什么常在同一个系统里出现

    考虑下棋系统:搜索负责在候选行动和后续局面中展开比较;评估函数估计局面价值;规则限定哪些行动合法。评估函数可以由人工设计,也可以从数据学习。搜索不是机器学习的同义词,但可以使用学习得到的组件。

    再看一个检索问答系统:检索器寻找相关材料,模型生成回答,规则控制权限与输出格式。文本来自检索,不说明模型在本次调用中更新了权重;外部记忆增加,也不自动等于参数学习。判断系统时,应追踪信息在什么位置被保存、何时影响后续行为。

    这种拆解还能防止把能力归错原因。若系统回答了一个新事实,可能是训练中见过,可能是检索到了,也可能是上下文直接提供。只凭最终答案,不能断定哪种机制起作用。需要关闭或替换对应组件,构造受控比较。

    因此,AI作为系统任务、ML作为获取某些能力的方法、DL作为一种模型与表示学习路线,可以同时成立。产品层面往往是组合关系,研究分类则帮助我们提出更可验证的问题。

    评价模型,至少把四个问题分开

    表达能力问是否存在一组参数能完成任务;可学习性问在给定数据与假设下能否学到有用规律;可优化性问训练算法能否实际找到合适参数;系统可用性还要问延迟、成本、失败与维护。四者不会由一个标签自动保证。

    一个很大的网络可能表达目标函数,却因数据不足而无法稳定学习;一个可证明适合的统计模型,也可能因为实现错误而训练失败;测试误差较低的模型,还可能不满足部署资源与隐私条件。

    这就是为什么技术报告应交代任务、数据、模型、训练、评估和运行条件。称为深度学习,只提供了部分机制线索;它不能代替对整条证据链的检查。

    让XOR网络真正算一遍,区分表示与训练

    沿用二值x₁、x₂,令u=x₁+x₂,h₁=ReLU(u),h₂=ReLU(u−1),输出y=h₁−2h₂。输入00时u=0,输出0;输入01或10时u=1,输出1;输入11时u=2,输出2−2=0。这个两单元隐藏表示已经能实现XOR。

    这里权重是人为构造出来的,所以这次演示证明的是该模型族具有表达能力,不是证明某种训练算法必然能学到它。若真实训练失败,可能是初始化、目标函数、优化过程或数据编码的问题;表达能力充足不能替代这些条件。

    它也没有说明XOR需要“非常深”的模型。一个小型非线性表示就够用。现代深度学习的复杂性来自更广的任务、表示复合与训练规模,不能用一个玩具函数为任意增加层数背书。

    把这几个结论分开,就能更精确地阅读“某架构解决某任务”的报告:作者展示了存在性构造,训练得到的结果,还是在未见数据上的稳定表现?它们分别需要不同证据。

    模型与完整系统还差一层

    实际产品还包括数据清理、检索、规则、权限、监控和人工复核。一个应用可以同时使用手写规则与深度模型;它的可靠性不能只用模型在一张公开测试表里的分数表示。

    这些方法类别不能代替对实际系统的评估。“AI”不能直接推出会学习,“机器学习”不能直接推出使用神经网络,“深度学习”不能直接推出接近人类理解。

    以后遇到一个新系统,可以依次问:它完成什么任务,哪部分来自人工规则,哪些参数由数据确定,如何评价未见输入,以及失败由谁发现。这些问题比争夺一个标签更接近理解机制。

    来源、证据与阅读路线

    1. McCarthy等,1955年达特茅斯AI研究项目建议书,开篇及研究主题
    2. Goodfellow、Bengio、Courville,Deep Learning,2016,第1章,尤其图1.4与表示学习讨论
    3. Tom Mitchell,Machine Learning,1997,作者提供的教材入口与第1章

    证据范围:AI分类依据教材与所列任务定义;XOR网络是手工构造并核对有限输入的例子,没有进行数据训练或设备性能测量。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 0 和 1 为什么能表示整个数字世界

    作者:lihao技术文章发布:计算机科学基础 · 第 02 期

    从四位数、UTF-8文字和灰度像素出发,说明有限状态怎样通过编码组合成数字媒体,以及位串为什么不能自己说明含义。

    两种状态很少,组合却可以很多

    一个开关只有开和关;四个独立开关却有16种组合。计算机把两种可区分状态抽象为0和1,称为一个比特(bit)。n个比特有2的n次方种位串:这是组合数量,不是每个位置都藏着丰富内容。

    物理上不必是“完全没电”和“有电”。电路使用允许范围内可区分的电平等状态,并处理噪声、时序和可靠性。二进制是一种适合构造稳定数字逻辑的表示,不意味着现实世界本来只有两种事物。

    要让位串表示数字、文字或图像,需要编码规则。表示能力来自组合和约定;理解含义则还依赖格式、上下文与解释程序。

    先让位串表示一个整数

    约定四位无符号二进制从左到右权重为8、4、2、1,那么1101表示8+4+0+1=13。全部四位位串可以表示0到15,总共16个值。若想表示16,必须扩展位数,或更换编码。

    同样的1111,在四位无符号解释下是15,在四位二进制补码解释下是-1。这不是电路同时看见了两个数,而是解释规则不同。位宽、符号规则与字节顺序等,都是数据格式的一部分。

    因此,“计算机里全是0和1,所以类型不重要”恰好说反了:正因为底层位串可以有多种解释,类型和协议才帮助我们排除错误解释。这里的补码只是反例,负数的完整运算规则留待后续。

    文字不是把字形直接塞进一个数

    Unicode为字符分配码点;UTF-8再把码点编码为一个或多个字节。字符A的码点为U+0041,UTF-8字节是十六进制41;“中”为U+4E2D,UTF-8字节是E4 B8 AD。[1] 十六进制只是紧凑的记数方式,每一位十六进制数对应四个比特。

    这个字节序列可以手算。U+4E2D 写成 16 位二进制是 0100 1110 0010 1101;它处于 UTF-8 的三字节编码范围,采用 1110xxxx 10xxxxxx 10xxxxxx 的形式。把码点有效位分成 0100、111000、101101,填入三个位置,得到 11100100、10111000、10101101,也就是 E4 B8 AD。[4] 这里示范的是 U+4E2D,不能省略 UTF-8 对代理项、范围和最短形式的合法性约束,再把所有 16 位整数都当字符编码。

    一字节通常为8位,但“一个字等于一个字节”不成立。字符、码点、编码单元以及屏幕上看到的字形并非一一对应;组合附加符号和表情序列尤其能显示这种区别。

    收到E4 B8 AD之后,软件还需要知道按UTF-8解码,再由字体和排版系统绘制字形。若拿错误编码读取,位串没变,屏幕却可能乱码。Unicode的编码规则支持信息交换,不替我们决定一句话是否正确或有意义。

    图像和声音怎样进入同一套表示

    构造一张2×2的8位灰度图,约定按行保存四个像素,数值为0、85、170、255。像素负载是4×8=32比特,即4字节;0最暗、255最亮。真实文件还要描述尺寸、排列、颜色空间等,不能把4字节负载当作完整文件大小。

    照片先经过空间采样与数值量化,保留有限个位置与有限精度。声音也可以在一系列时刻记录量化后的振幅。下面只作未压缩双声道PCM的负载算例:每秒48000次采样、每个声道每次16位,负载为48000×16×2=1536000比特/秒,即192000字节/秒。

    这个算例不含容器头、元数据与压缩,也不保证采样后的声音与原始连续信号完全相同。编码能保存我们选择保留的信息,不能让有限数据无损覆盖任意连续细节。

    压缩和加密改变的是什么

    如果图像有大量重复或可预测结构,无损压缩可能用更少的位保存它;解压后恢复原位串。有损压缩允许丢掉某些信息,以换取更小体积。加密则依据密钥改变可读取的形式,目标也不同。

    不可能让所有固定长度位串都无损压得更短:短位串的总数量不足以给每个输入分配不同输出。压缩方法利用输入的结构和分布,不会推翻这个计数事实。Shannon在1948年的论文中区分了通信工程问题与消息的语义问题。[2] 这一区分有助于避免把“信息量”直接等同于思想深度。

    所以,0和1能共同承载数字媒体,并不表示所有东西都能被有限、精确地表示,也不表示机器理解了内容。真正可用的数字世界建立在位串之上的多层契约:编码、格式、程序、设备和使用语境。

    一张像素图,需要的不只是四个亮度值

    继续使用0、85、170、255四个字节。若约定宽2、高2、逐行存储,就形成左上最暗、右下最亮的2×2灰度图。若约定宽4、高1,同样负载变成一行;若把相同字节解释为颜色通道,又会得到别的对象。

    因此,一个可交换的格式至少要交代尺寸、通道、数值精度、顺序与解释方法。像素数据像一串没有标点的数字,格式负责说明如何分组。头部或外部协议并非无用开销,而是让另一端能恢复对象的必要条件。

    还要区分像素值与显示亮度。8位灰度数值只是编码,显示器的响应、色彩管理和观察环境会影响看到的光。数值128也不必自动等于人眼感觉上的“一半亮”。本例只说明编码容量,不用数值表冒充视觉质量实验。

    如果把每个像素从8位减为1位,每个位置只剩两种取值,四个位置总负载降到4比特;原来的85和170就无法原样保留。体积变小来自表示空间变小,必须决定如何近似,而不是认为二进制可以无代价保留所有细节。

    声音的采样率和位深,分别限制什么

    采样率描述每秒取多少个样本,位深描述每个样本用多少位表示。增加采样率主要改变时间轴上的离散程度;增加位深主要改变幅值量化的等级。它们是不同维度,不能用其中一个数字代替全部音质。

    前面的48kHz、16位、双声道PCM,每秒有96000个声道样本,每个2字节,因此是192000字节/秒。持续60秒,原始负载为11520000字节;按十进制MB约11.52MB,按2的20次方字节为1MiB计算,约10.99MiB。文件容器与元数据还要另外计算。

    这是容量推导,不是“某音乐文件一定这么大”。压缩编码可以改变文件大小,静音也可能被不同方式表示;编码器的质量、失真目标和音源本身都相关。把码率、采样率和响度混为一谈,会误解每个数字能够支持的判断。

    一个连续变化的物理波形,只有在适当信号条件、采样过程与重建条件下,才能讨论如何由离散样本恢复。这里不展开采样定理的证明,但必须保留条件;不能仅凭“比特足够多”宣称任何声音都能被有限数据精确保存。

    比特翻转、压缩和意义,不能混用同一标准

    若00000001中的最高位因错误变成1,按8位无符号数读取就成129;若它处在文件头,影响又可能大得多。相同数量的比特错误,对不同格式的后果并不相同。可靠传输需要检错或纠错等机制,编码能表示内容并不保证传输没有损坏。

    再看压缩的计数限制:一共有256种8位输入,而长度至多7位的位串总共只有1+2+4+…+128=255种,其中还计入空串。若要求每个8位输入都能无损恢复,就无法把它们全部映射成互不相同的更短串。

    现实压缩能工作,是因为某些输入更常见,或输入具有可利用的重复结构;方法可以让常见内容缩短,同时允许别的内容不缩短甚至变长。它与“任何文件都能反复压小”不是一回事。

    最后,65是一个数,A是按规则读出的字符,某段文字是否有意义又是另一层。信息论中的量化对象、编码后的对象和人理解的内容,应在各自问题中使用。位串是共同的承载形式,语义来自更完整的解释关系。

    一个屏幕上的“字”,为什么不一定是一个码点

    字节计数、码点计数和用户看到的字符数,可能不同。例如某些带附加符号的文字既可用预组合码点表示,也可用基础字符加组合符号表示。视觉上相近,不保证底层码点序列逐字相同。

    Unicode文本分割规范用扩展字素簇等规则处理用户可感知的文本边界。[3] 这不等于每一种语言和应用都只需一个字符计数函数;光标移动、字符串长度、截断与存储限制可能采用不同单位,必须明确接口。

    如果把UTF-8字节数组截在一个多字节序列中间,可能得到非法编码;即使保留完整码点,也可能截断一个组合序列。正确解码、正确分割与正确显示是相连但不同的工作。

    对程序接口,这些区别会直接影响字段长度与校验。要求“最多10个字符”应先解释10个字节、10个码点还是10个字素簇。把含糊需求变成具体编码契约,才让“0和1可以表示文字”成为可靠的数据交换机制。

    自己检验一次解释规则

    把01000001按8位无符号数读,是65;按ASCII兼容的UTF-8字节读,是A;按单通道像素强度读,是一个灰度值。同一串比特没有自己携带唯一用途。

    阅读文件或接口时,先问“位宽多少、用什么编码、怎样划分边界、由谁解释”,通常比只问“里面是不是二进制”更有帮助。本篇算例是编码与计数推导,不是图像质量、音频质量或电路可靠性的实测。

    来源、证据与阅读路线

    1. Unicode FAQ:UTF-8、UTF-16、UTF-32与编码单位;U+0041、U+4E2D为本文编码例子
    2. C. E. Shannon,A Mathematical Theory of Communication,1948,Introduction;大学托管原论文
    3. Unicode Standard Annex #29:Unicode Text Segmentation,扩展字素簇;浮动规范以文中概念说明为限
    4. Unicode Standard 18.0.0,Chapter 3,3.9 Unicode Encoding Forms;Table 3-6 与 3-7 的 UTF-8 位分布及合法序列

    证据范围:字符编码、像素与PCM容量、有限串计数按明确假设核对;这些算例不等于显示设备、音频链路或压缩实现的实测。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 计算机到底是什么?从信息表示、计算到输入输出

    作者:lihao技术文章发布:计算机科学基础 · 第 01 期

    用一个三条指令的教学模型,连接数据编码、状态变化与输入输出;说明程序可运行和系统可用之间还差哪些条件。

    计算机为什么不只是算得快的机器

    一台机器能做加法,还不足以说明它为什么能编辑文字、播放声音或浏览网页。关键是:不同对象能按规则表示为数据,同一套可编程机制能依据指令变换数据,再通过输入输出连接外部环境。

    本文讨论数字电子计算机。它通常有处理器、存储器和输入输出设备;程序规定要执行的操作,存储状态保存当前的数据与进度。计算机不是直接认识一张照片或一句话,而是在具体编码、软件与设备协议下处理表示。模拟计算机、量子计算等不在本篇模型范围内。

    Nand2Tetris 的计算机体系结构课程把CPU、内存与映射到地址空间的设备组合起来,是观察这种分工的教学入口。[1] 这里另构造一个更小的模型,避免先被具体指令集淹没。

    把“输入一个数,输出它加一”拆开

    约定输入只允许0到254之间的整数,数据寄存器R是8位无符号数,程序只有三条指令:IN R;ADD R,1;OUT R。另有程序计数器PC指出下一条指令,开始为0。

    输入41时,第一步把外部输入解释为整数41并放进R,PC变1;第二步计算42并写回R,PC变2;第三步把R的值送到输出端,PC变3并停止。状态可以写成“PC、R、输出记录”的组合。指令不是结果本身,而是规定状态怎样变化的规则。

    若输入是字符“41”,输入层还需要先把两个字符解析为整数;若输出设备是屏幕,还要把42转成字符编码并绘制字形。我们为教学把这些工作藏进IN和OUT,因此不能用这个模型声称真实机器一条指令就完成了键盘或屏幕操作。

    一段可以亲手核对的状态轨迹

    本例的抽象轨迹如下,横线表示尚未写入。它是教学模型的手工推演,不是某种CPU的指令跟踪:

    执行后 PC R 输出
    初始 0 — 空
    IN 1 41 空
    ADD 2 42 空
    OUT 3 42 42

    读这个表应能回答:数据是什么时候进入的,哪一步改变了数值,哪一步产生了外部可见结果。如果计算完成却没有执行OUT,内部状态已有42,用户仍可能看不到输出。

    模型还必须规定错误。我们拒绝255,是因为“加一仍可表示”属于本例输入契约;另一种机器可以选择模256回绕,得到0,但那是另一套语义。不能一边说得到数学整数的加一,一边悄悄按固定位宽截断。

    程序怎样让同一台机器做不同的事

    把ADD换成别的操作,或加入条件跳转,就能改变执行路径;重复读取、比较与写入,可以组成复杂流程。程序把任务组织成步骤,处理器实现这些步骤的基本机制。程序和数据在许多现代系统中都保存在可寻址存储器里,但取指、执行、缓存与权限有各自规则。

    实际CPU会有流水、乱序执行、多级缓存,设备也可能通过DMA搬运数据。上表按顺序描述可观察语义,不声称每条指令恰好占一个时钟周期。顺序模型有助于解释“算什么”,还不足以预测“多久算完”。

    更换程序能扩大任务范围,也不能消除资源与可计算性的限制。有限内存装不下任意大的输入;某些任务可能需要不可接受的时间;也不是每个有意义的问题都能由通用算法决定。

    给模型加一次分支,程序就能作选择

    前面的加一程序只有固定路径。现在构造另一个任务:读入一个0到255之间的整数,若等于0就输出0,否则输出1。为此增加一条条件跳转:R等于0时,把PC改到“输出0”的位置;否则继续到“输出1”的位置。

    这时PC不再只是每次加一。输入0与输入41,会让同一份程序走过不同指令;选择由寄存器中的数据与跳转规则共同决定。这足以说明条件执行的基本机制,不需要先把“判断”解释成人的理解或意志。

    再加入向前面位置跳转,就能重复一段操作。例如不断读取值并累计,遇到终止标记后输出总和。循环让有限长度的程序描述任意多次重复,但每次运行仍受输入、时间和存储限制。一个没有正确终止条件的循环,也可能一直无法产生用户等待的输出。

    这里有三个需要分别检查的对象:程序文本是否包含正确分支,运行时数据使哪条分支被选择,以及输出是否满足任务。只看源代码里出现“if”,并不知道本次运行发生了什么。后续读控制流图时,正是在更明确地记录这些可能路径。

    存储器里的地址,与现实世界的对象

    把存储器先看成一排编号盒子。地址用于找到某个位置,内容是该位置保存的位串。地址8与其中保存的数8可以碰巧一样,但含义不同;修改盒子里的内容,并不自动改变盒子的编号。

    程序可以用多个位置表示一个对象,例如字符数组或一张图。对象边界由格式和程序决定,不是每个地址天然对应一个完整意义。读取四个字节究竟得到一个整数、四个字符还是四个像素,还要看约定。

    在这个简化层次里,程序读写地址,CPU执行运算;实际机器还要处理缓存、虚拟地址和权限。虚拟地址不是承诺某个固定物理位置,缓存中的副本也可能让观察更复杂。但这些机制并没有取消“必须说明读写哪个对象”的责任。

    一个有帮助的区分是存储容量与运算能力。更大的内存可以保存更多状态,却不直接让每次加法更快;更高的运算吞吐也不保证输入能及时送达。计算是状态变换,存储与输入输出决定这些状态怎样获得、保存和离开。

    从用户按键到屏幕结果,谁负责哪一步

    以键入“41”并显示“42”为例,可以列出五个层次:键盘事件被接收;输入程序得到字符序列;解析器把十进制文本转换为整数;算术过程加一;输出程序再把整数格式化为字符并交给显示系统。

    如果输入是“4l”,末尾是字母l,解析器应拒绝、提示还是截断?如果只接受整数,输入“41.5”又怎样处理?这些选择不是ALU能替用户决定的。算术单元忠实执行它收到的位模式,系统仍可能因为接口处理错误而失败。

    程序计数器、寄存器与内存因此提供了机制解释;用户任务还需要数据契约。一个结果可见但错误,和一个结果正确却没有送达,属于不同失败。定位问题时,沿数据经过的阶段逐步检查,比把一切归结为“电脑没算对”更有效。

    这也解释了为什么“计算机是输入、处理、输出”虽不错,却还不完整:我们必须进一步说明表示规则、可保存的状态、控制流程与每个边界的责任,才能理解同一设备怎样承载不同软件。

    抽象机器怎样帮助我们判断两种实现是否等价

    把机器状态记成s=(PC,R,M,I,O),分别代表控制位置、寄存器、内存、剩余输入与已产生输出。每条指令定义一项状态转换;一段程序的运行,就是从初始状态出发形成的转换序列。这个表示比“它算了一下”更明确,因为可以指出哪一项状态发生变化。

    两种实现内部轨迹可以完全不同,却在规定输入上产生相同可观察输出。例如一台机器用专用加法器,一台分两次较小运算得到结果。若接口只承诺最终值,内部差异可能不影响功能等价;若接口还承诺每个时刻的输出与响应时间,就需要更强关系。

    因此等价总要说“相对于哪些观察”。忽略耗时的数学函数模型不能证明实时响应,忽略异常的模型不能证明失败行为一致。抽象有价值,是因为它主动舍弃一些细节以回答一个问题;当问题变了,就要检查被舍弃的细节是否重新重要。

    本例的顺序状态机可以解释指令与数据怎样协作,但没有多线程、缓存一致性和设备并发。它为后续系统学习建立语言,不能靠一个小模型承诺现代计算机所有行为都如此简单。

    输入输出决定计算怎样成为一个系统

    传感器把温度等物理量转换成电信号和数字读数;网卡按协议收发数据;显示器把像素数据转成光。每个环节都可能有延迟、精度、失败和权限限制。程序算出一个数,与设备真正执行对应动作是不同事件。

    所以,“程序没有报错”并不等于结果满足用户需要。输入是否按约定解析、溢出是否被处理、输出是否送达,都是完整行为的一部分。若输入设备把41读成14,处理器忠实完成加一,系统仍然给错了用户想要的答案。

    这也是本篇的边界:它解释表示、状态变换与外部行为之间的关系,不提供真实硬件性能结论。下一篇继续追问:这些数、文字和图像,为什么可以共同建立在0与1之上?

    来源、证据与阅读路线

    1. Nand2Tetris,The Elements of Computing Systems,计算机体系结构与完整软硬件教学项目
    2. Unicode FAQ:UTF-8等编码形式,用于区分字符、编码和底层数据

    证据范围:抽象机器的状态轨迹和8位整数边界已按所定规则核对;这是教学模型,不是对真实处理器、操作系统或通用计算能力的实验验证。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 怎样重建一个哲学论证:前提、结论、反例与回应

    作者:lihao技术文章发布:哲学 · 第 02 期

    以《克里托篇》中不应以不义回报不义的论证为例,把原文、补入的前提与批评分开,练习提出真正相关的反例。

    理解一个观点,先找到它要你接受什么

    读到苏格拉底宁愿留在监狱也不愿逃走,很容易先问:他勇敢吗,或者他是不是太固执?这些问题可以讨论,但还没有触及论证:他提出了什么理由,理由能否支持结论?

    论证重建,就是把一段自然语言整理成可检查的支持关系:哪些话是前提,哪句话是结论,中间依靠什么推理。目标是准确呈现最值得回应的版本;不能把作者的话削弱到容易反驳,也不能替作者补出一套完全没有文本依据的理论。

    本文以柏拉图《克里托篇》49a—49e为材料。[1] 以下中文均是依据 Jowett 英译所作的释义,编号结构由本文补入,不是原文逐字引语。对话中的苏格拉底与历史人物之间,也不能不加区分。

    先限制范围,再列前提

    原段讨论受了不公正对待之后,是否可以用不公正行为回报。可先重建为:P1,任何情况下都不应行不义;P2,以不义回报别人,本身也是行不义;C,因此,即便先遭受不义,也不应以不义回报。

    P1提供规范性的总原则;P2把“报复性的不义”归入该原则;C是二者结合后的结论。只要词义保持一致、P1和P2都真,C就不能为假。这说明支持结构有效,还没有说明P1为什么值得接受。

    注意结论的范围:它不是“受害者应放弃一切抵抗”,因为制止伤害是否属于不义还需另论;它也不是“任何法律都必须服从”。从这一原则走到“苏格拉底不应逃走”,还要论证逃走会构成不义,不能省掉这一步。

    隐含前提必须显式标记

    若要推导“不应逃走”,至少还需要加入:P3,在这次具体处境中,逃走会构成不义。于是P1与P3才支持“不应逃走”。P3不是49a—49e已经独立证明的事实;对话随后借拟人化的法律讨论城邦、协议与义务。[1,50a—54d]

    重建时可以标记三类内容:原文明确提出的判断;从上下文有根据恢复的隐含前提;为检验而提出的更强或更弱版本。把这三者混在一起,容易出现一种假象:表面上在读原典,实际在证明自己的主张。

    一个简单检查是反问:删去这个前提,结论还能推出吗?若不能,它就是重要桥梁,必须交代根据。若作者根本没有承诺它,就应回到文本,或者明确说现在讨论的是本文改写后的论证。

    怎样提出最强的异议

    针对P1可以提出一个构造情境:若违反某项通常认可的规则,是阻止更严重伤害的唯一办法,是否仍然绝对禁止?这迫使我们区分“不义”本身和“违反一条通常有效的规则”。如果先把任何特殊情境下的正确行为都定义成“不算不义”,P1可能保住,却需要给出独立判断标准,避免变成空话。

    针对P3的异议更直接:错误的判决、受约束的选择与留下居住,是否足以形成必须接受这次判决的义务?这没有推翻“不要行不义”的形式结构,却挑战了把具体逃走归类为不义的理由。

    最强异议常常不是“我不喜欢你的结论”,而是:我暂且接受你的大原则,但你尚未证明眼前这个行为确实落在原则之下。这里是哲学评价,不是对现实案件的行动建议,也不是对全部政治义务理论的裁决。

    回应之后,结论是否悄悄换了

    回应有几种诚实方式:为原前提补理由;限制前提适用条件;承认反例并放弃一部分结论;说明反例不满足条件。每种方式都可能合理,重要的是记录变动。

    例如,把“所有有效协议都应履行”改成“在自愿、知情且内容正当时,协议产生初步义务”,就不能再无条件推出所有协议都必须履行。新论证可能更可靠,但范围变小了。这是修订,不是宣布原来已经说过同样的话。

    还要区分反例的任务:一个前提为真、结论为假的可能情形,可以推翻演绎有效性;一个真实事例与某个经验性全称判断冲突,可以推翻该判断;一个情境使我们犹豫,只能先算压力测试,仍要讨论它描述得是否一致。

    把一份阅读笔记真正填满

    针对《克里托篇》,可以把重建整理成四列,而不是只摘一句名言。第一列写原文位置:49a—49e讨论不行不义和不以不义回报;50a之后转向具体逃走与法律。第二列写本文提炼的主张;第三列写支持关系;第四列写尚待辩护的地方。

    例如,“任何情况下都不应行不义”是文本中的原则性承诺;“这次逃走是不义”是把原则用于当前行动时必须成立的分类判断;“所以不应逃走”是结论。前两项的重要性不同:前者若被拒绝,规范基础动摇;后者若被拒绝,仍可接受原则,却不同意这次应用。

    再把支持关系画成文字:P1+P3→不应逃走;P1+P2→不应以不义回报。两条线共享P1,但不是同一条论证。即使后一条很有说服力,也没有替前一条补上P3。自然语言中的段落连续,容易让我们误把共享话题当成已经证明的连接。

    这样整理以后,评论就能精确到某一条线:“我同意不能报复性地行不义,但没有看出逃走必然属于那类行为。”这比“苏格拉底的思想过时了”更容易得到回应,也更忠实于问题。

    善意解释与过度代写的界线

    设想一位作者说:“他有机会离开,却长期留下,所以他同意这里的安排。”善意重建可以补出“在选择自愿、信息充分且存在合理替代时,持续留下可作为同意的证据”。这让省略的条件显露出来。

    但如果原作者明确主张“留下本身就足以产生无条件义务”,我们不能把上述限制塞进去后,宣布作者原说法已经成功。此时应并列原版本和修订版本:原版本范围更大,也更容易遇到被迫留下、迁移成本过高等反例;修订版本较强,却承担证明这些条件满足的责任。

    同样,不应把原作者最脆弱的一句话孤立出来,忽略紧接着给出的限定。重建不是尽量让作者赢,也不是尽量让作者输,而是呈现一个受文本约束、值得真正回答的论证。若存在两种合理解释,就把分歧公开,不用一种解释伪装成唯一读法。

    实际笔记可以给补入前提加“解释性补充”标签,并说明上下文依据。没有依据时,改称“受该文本启发的独立论证”。这两类工作都可以有价值,只是不能混用作者的权威。

    一个好反例,需要通过哪些检查

    首先检查相关性。若结论只涉及故意报复的不义,拿无意事故作反例,就没有击中它的适用范围。其次检查内部一致性:不能一方面设定没有其他可用方案,一方面又在回应时偷偷提供一条无代价的替代方案。

    第三,检查攻击目标。一个例子若只说明某前提不真实,不能因此断言推理结构无效;一个可能情境能显示结构不保真,也不必在现实中已经发生。经验反证与逻辑反模型的证据任务不同。

    最后,说明代价。如果为了躲开反例,原则被改得极窄,以至于几乎没有行动会落入其中,那么它虽然较难被反驳,却可能失去原来要解释的内容。稳健的回应应在抵抗反例与保留解释力之间承担责任。

    对话结束前,还可以写一份“修订账”:原前提、异议、回应、新前提、结论范围变化。这个账让我们看见观点是否真正改善,而不是围着同一个词重复站队。

    理由是共同起作用,还是各自提供支持

    “所有人都会死亡;苏格拉底是人;所以苏格拉底会死亡”中,两个前提共同支持结论。只剩其中任意一句,都不足以推出特定结论。这是联合支持,不能把每个前提单独削弱后说原论证已经没有力量。

    另一种结构是:某结论有一份档案记录支持,又有独立实物证据支持。两条线可能分别提供理由,但“独立”需要检查;若档案与解说都转述同一份未核实报道,就不是两份独立证据。哲学文本中也常有多个论证指向同一结论,不能击败其中一条就假定全部失效。

    对于《克里托篇》,无伤害原则、协议、城邦关系等可以有不同重建方式。本文只把49a附近的一个局部论证拆出,不宣称已经穷尽全篇。整篇评价还要比较各理由之间是并列、互相依赖,还是在对话中逐步变更。

    完成重建后,可以删除一项前提,检查哪些结论随之失去支持;再暂时保留它,检查最强异议打在哪一条连接。这种依赖检查,能把“这一段很有道理”的整体印象变成可逐步审查的判断。

    把方法带到下一段阅读

    实际做笔记时,写出文本与分节、关键词的译法、目标结论、编号前提、每一步支持关系、最强异议和作者可能的回应。最后用一段话说明:哪些是原作者的话,哪些是你的解释,目前保留什么疑问。

    检验标准不是能否迅速赢得争论,而是对方能否认出自己的观点,以及旁观者能否指出分歧发生在哪一步。论证重建不能代替历史语境、修辞与整本书的阅读,但它能阻止我们把人物故事当成已经成立的道理。

    来源、证据与阅读路线

    1. 柏拉图《克里托篇》49a—49e及50a—54d,Benjamin Jowett 英译;本文重建并非逐字翻译
    2. Stanford Encyclopedia of Philosophy:Logical Consequence,关于保真与反例的分析;作为方法补充

    证据范围:本文对《克里托篇》的局部论证作文本约束下的重建,并用构造反例检查支持关系;不宣称穷尽全篇或复原历史人物的全部动机。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 哲学到底是什么?它研究的问题和科学有什么不同

    作者:lihao技术文章发布:哲学 · 第 01 期

    从“多数人赞成就等于正确吗”出发,区分经验事实、概念与规范问题,说明哲学怎样通过论证接受批评,以及它与科学的相互约束。

    同一场争论,可能在问三件不同的事

    假设一个读书小组决定:以后都用投票选择书目。有人说,多数人赞成的安排就是公平的。另一个人反对:如果少数人的阅读兴趣永远被忽略,票数再多也不公平。这是一个构造案例,但分歧很真实:大家究竟在争论人数、规则的效果,还是公平的含义?

    统计投票可以回答谁赞成什么;长期记录可以研究规则是否提高参与率;它们却不能单独决定“多数人的偏好是否应当拥有最终权威”。要得出后一结论,还需要解释为什么人数具有道德分量,以及少数人的利益应受到什么保护。

    本文把哲学暂时理解为:对存在、知识、价值和推理等根本问题,澄清概念、提出理由,并检验这些理由。这是便于入门的工作定义,不是所有哲学传统都认可的唯一边界。哲学也不是一套替人决定人生的现成答案。

    哲学会把问题推进到哪里

    “世界里有什么”涉及存在与实在;“我凭什么知道”涉及知识与证成;“什么值得追求、应当怎样行动”涉及价值与规范;“结论是否由理由支持”涉及逻辑。四类问题会交叉,却不能因为都很抽象就混成一句“看问题的角度不同”。

    例如,眼睛看见桌面是红色,是一次经验;在特定照明条件下测量反射光,是可重复的观察;追问“颜色是物体的性质、经验的性质,还是两者的关系”,又改变了问题层次。哲学讨论仍要尊重视觉科学,不能凭概念推理否认已经获得的可靠证据。

    亚里士多德《形而上学》第一卷第一、二章把求知与原因、原理联系起来。[1] 这能帮助理解一种历史上的哲学追求,不能把其中的 science 直接套成现代分科后的实验科学;古代知识分类与今天并不相同。

    科学与哲学怎样分工,又怎样相遇

    一种实用的区分是看争议如何被推进。若两个说法对同一条件下的可观察结果给出不同预期,可以设计测量、比较模型与误差。若双方认同全部测量结果,却仍对什么算解释、什么算正当理由有分歧,就需要进一步检查概念和论证。

    这不是“科学只问怎么、哲学只问为什么”。科学同样解释原因,哲学也研究具体行动;认知科学、科学哲学和伦理学常在同一问题上合作。区别在于当前问题需要哪些证据,而不是给研究对象划一道永远不动的墙。

    哲学也不能被定义为科学尚未解决的问题的仓库。有些历史问题会随着方法发展形成专门学科;但知识的标准、研究目的和价值冲突,不会因为仪器更精确就自动消失。反过来,把一个可用实验区分的事实争议永远留作“哲学意见”,也会阻碍理解。

    把“多数就是公平”改写成可讨论的论证

    先重建一个较强版本:每个人的同等利益应受到同等考虑;在成员权利受保护、选项可理解、参与机会平等时,多数决是协调共同选择的一种合理程序;这次投票满足这些条件;所以本次结果有初步的程序正当性。

    这比“票多所以正确”多了关键条件,也缩小了结论。现在可以提出有针对性的异议:若某成员无法参与,条件是否成立?若选项侵犯基本权利,投票是否仍有决定力?如果两本书差别很小而轮换成本很低,轮换能否更好照顾少数人?

    回应不能只说“凡事都有两面”。可以修订规则,例如保留少数提名、轮换或退出的机会;也可以保留多数决,但承认它只能决定本次共同安排,不能证明胜出的书文学价值最高。这里展示的是本文的论证重建,不是某位古人的原话。

    没有统一答案,是否意味着随便说都对

    没有共识不等于没有评价标准。我们仍可检查术语是否前后一致、理由是否支持结论、反例是否击中了条件、回应是否偷偷换了问题。一个观点可以在这些方面更好,即使尚未终结争论。

    柏拉图《欧绪弗洛篇》5d—6e区分了举出虔敬行为的例子与给出能说明这些行为的定义;7a—8b又用神之间的分歧检查一个定义。[2] 本文将这些步骤理解为概念澄清与反例检验的示范,不把对话人物的每句话当成柏拉图已经证明的学说。

    最强的异议是:哲学自己的定义和判断方法也有争议。这个异议成立,它提醒我们,本篇给的是一条入门路径,不能覆盖现象学、诠释学等全部方法。但方法有争论,并不免除提出理由的责任。

    事实再充分,为什么仍可能留下规范分歧

    回到读书小组。假设我们已经知道:多数决能让平均满意度从6分升到8分,但两位成员连续六次都读不到自己想读的书。这些数字是构造数据,只用来把分歧固定下来。

    一种主张说,总体满意度提高,所以规则值得保留。另一种主张说,每个人应有最低限度的参与机会,不能只看总和。双方可以完全同意调查数据,却采用不同的评价原则。继续增加样本量能够提高对效果的判断,却不会自动替我们决定总和与机会公平之间的优先关系。

    这不说明事实与价值彼此隔绝。如果少数成员其实轮流获选,第二种担忧会减弱;若轮换造成大多数人退出,支持轮换者也必须回应这个代价。规范论证需要吸收事实,但由事实走到“应当”,通常仍需要某种价值前提。

    可以把第一种说法拆成:在不侵犯其他重要要求时,提高成员的总体满意度是理由;这套规则确实提高满意度;没有更重要的反对理由;所以暂时保留。真正容易被藏起的是第三步。哲学讨论往往从这里开始:什么算重要要求,为什么它能限制一种看似有效的安排?

    一个原典问题:被赞许,与值得赞许

    《欧绪弗洛篇》后来把问题推进到10a—11b:虔敬的事物,是因为虔敬而被诸神喜爱,还是因为被喜爱而成为虔敬?这里的“因为”在问解释方向,不能简单当成时间先后。[2]

    不妨换成一个仅用于帮助理解的课堂例子:一份答案因为正确而得到老师表扬,还是只要得到表扬就因此正确?“所有正确答案都受到表扬”,即使在这个课堂碰巧成立,也还没有说明正确由什么构成。表扬可以是识别线索,却未必是正确的根据。

    类比只能帮助看见结构,不能替代原典。宗教语境中的诸神、虔敬与善,与老师和答案并不相同;后来的神命论也可能提出更复杂的回应,例如把规范根据联系到神的本性,而非任意偏好。本篇不凭这个简化问题宣布已经驳倒全部宗教伦理。

    这段对话展示了哲学的一项工作:当一个定义似乎圈中了全部例子,还要问它是否真正解释对象。外延恰好重合,与说明“何以如此”,不是同一种成功。这个问题也解释了哲学为什么既关心定义,又不能停留在查字典。

    哲学如何取得进展,而不只是保留疑问

    一种进展是排除含混:把“多数赞成”与“多数有权决定”分开。另一种是发现不相容:你不能一方面说每个人机会同等重要,一方面又拒绝解释某些人永远没有机会。还有一种是建立更好的区分,使原先捆在一起的问题可以分别处理。

    进展不一定表现为最后所有人同意一句答案。一个争论若从口号走到明确的条件、可辨认的异议和更小的结论,已经增加了理解。当然,区分也可能过度精细、脱离问题;评价它仍要看是否帮助解释真实分歧,而不是术语数量。

    “哲学有什么用”也不能只有工具性的回答。澄清行动与知识标准能改善判断,但理解什么值得追求、怎样看待自己与他人,本身也可能有价值。是否接受这种价值,同样可以要求理由。

    最有力的反对意见:科学能否最终接管这些问题

    一种自然主义立场会质疑前面的分工:知识、道德和心灵都属于自然世界,哲学为什么要另设方法?心理学可以研究人怎样作判断,进化研究可以解释某些倾向怎样形成,社会科学也能比较制度后果。哲学若不吸收这些成果,确实容易讨论一个不存在的人类。[3]

    但“人实际上怎样判断”“判断机制怎样形成”与“这些判断凭什么值得接受”仍应分别表达。若某偏见能被进化过程解释,这个起源既不自动证明它正确,也不单独证明它错误。需要继续说明该形成过程与当前判断的可靠性有什么关系。

    自然主义者可以回应:证成标准本身也应在实际认知实践中检验,而不必诉诸超出科学的立场。反对者则会问,我们凭什么选择某种成功指标,如何避免用正在被质疑的方法为自己背书。这里的真正争点是方法的连续性、规范性和反思位置,不是简单的“哲学相信思想,科学相信事实”。

    本篇因此不承诺哲学拥有永远独立于科学的领地,也不承诺任何经验研究都能代替规范论证。更稳妥的做法是跟随具体问题:哪些环节需要事实,哪些环节需要概念关系,哪些原则仍在争论。学科标签应帮助组织工作,不能预先决定答案。

    读完以后,怎样开始一次哲学讨论

    找一句你愿意捍卫的判断,分别写下:它主张什么、理由是什么、哪些经验事实有关、什么情况会迫使你修改它。如果一句话无法区分这些部分,先缩小问题。

    哲学的价值不只在于服务其他学科,也在于认真追问生活、知识和世界本身。下一篇会把“给理由”拆成前提、结论、反例与回应,直接分析一段原典论证。

    来源、证据与阅读路线

    1. 亚里士多德《形而上学》第一卷第1—2章,W. D. Ross 英译;本文为中文释义
    2. 柏拉图《欧绪弗洛篇》5d—8b及10a—11b,Benjamin Jowett英译;中文为释义,非逐字引文
    3. Stanford Encyclopedia of Philosophy:Naturalism,第2节方法论自然主义与哲学/科学关系;本文呈现争点而非判定唯一立场

    证据范围:本文采用原典释义、概念分析与构造案例;自然主义等争点保留不同理论的回应,不把案例视为对全部哲学立场的裁决。

    版本1.2 · 2026-10-18:完成事实、条件与引用复核,逐篇明确证据方法;有实质缺口处补齐推导、反例或原题。原网址与首次发布日期保留。

  • 第一次阅读 llvm-project:源码目录、构建与调试入口

    作者:lihao技术文章发布:LLVM 与异构编译器 · 第 10 期

    从已运行过的 instcombine 命令进入 llvm-project,固定提交,找到入口、实现与测试;记录一次最小源码构建及测试依赖缺失,区分各项证据的范围。

    先带着一个小问题进源码

    第一次打开 llvm-project,最容易做的事是把目录从上往下翻一遍。这样很快会记住不少名字,却未必能回答一次实际编译发生了什么。更合适的起点,是带着上一期已经运行过的命令:opt -passes=instcombine。

    这次只追三件事:命令从哪里进入;instcombine 在哪里注册和实现;哪份测试描述了预期结果。先把这条短路径走通,再扩展到其他 Pass、前端和后端。

    先固定提交,再谈代码位置

    本次使用 LLVM 18.1.3 的固定提交:

    c13b7485b87909fcf739f62cfa382b55407433c0

    构建环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3、LLD 18.1.3、CMake 3.28.3、Ninja 1.11.1。

    下面用相对目录展示命令。长期实验请在 WSL 的 Linux 文件系统中选择可保留的工作目录,不把 /tmp 当交付仓库;退出环境后能否保留临时文件,不应成为证据链的前提。先保存版本与构建日志。

    git clone --depth 1 --branch llvmorg-18.1.3 --single-branch   --filter=blob:none --sparse https://github.com/llvm/llvm-project.git
    git -C llvm-project sparse-checkout set llvm cmake third-party
    git -C llvm-project checkout c13b7485b87909fcf739f62cfa382b55407433c0
    git -C llvm-project rev-parse HEAD

    本次实际取得的 HEAD 与上面的值一致。浅克隆限制历史,稀疏检出限制工作树;需要 Clang、LLD 等其他源码时再加入相应目录。不要因为本次没有检出某个目录,就认为仓库中没有那个项目。

    只记与这个问题有关的目录

    位置这里先看什么
    llvm/tools/opt命令行工具入口与新Pass管理器驱动
    llvm/lib/PassesPass名称解析、注册与流水线组织
    llvm/lib/Transforms/InstCombine指令组合变换实现
    llvm/include/llvm/IRIR相关类型与接口声明
    llvm/test/Transforms/InstCombine输入IR与对应回归预期
    llvm/unittests以代码形式编写的单元测试;与test目录的用例不同
    llvm/lib/Target目标后端;本次只在构建配置中选择X86

    仓库顶层的 clang、lld、mlir 等分别承担其他项目的工作,不能把仓库名 llvm-project 和其中的 llvm 子目录当成同一个层次。本次观察 IR 变换,先把注意力放在 llvm 内部。

    构建需要的工具,保留配置边界

    先将 LLD设为可用的 ld.lld 绝对路径,并检查版本。若系统已安装 lld-18,可通过 command -v ld.lld-18查找;本次使用的是本地解压路径。然后配置:

    cmake -S llvm-project/llvm -B build -G Ninja   -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18   -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_ASSERTIONS=ON   -DLLVM_TARGETS_TO_BUILD=X86 -DLLVM_ENABLE_PROJECTS=   -DLLVM_INCLUDE_TESTS=ON -DLLVM_INCLUDE_BENCHMARKS=OFF   -DLLVM_BUILD_TOOLS=ON -DLLVM_USE_LINKER="$LLD"
    cmake --build build --target opt FileCheck --parallel 2
    build/bin/opt --version
    build/bin/FileCheck --version

    本次 opt 与 FileCheck 均构建成功,版本输出为 18.1.3,并显示带断言的优化构建。配置中只选择 X86,未构建 Clang、全部目标或完整工具集合。opt 仍会依赖许多库,“只构建两个工具”不等于只编译两个源文件。

    Release 加断言适合这次命令行观察,不能据此说已经有适合逐行调试的完整调试信息。如果后续确实需要断点调试,应另建 Debug 或 RelWithDebInfo 构建,并重新记录配置、磁盘与内存需求。这里没有声称已经完成断点调试。

    测试入口失败,要分清失败发生在哪里

    构建之后,本次尝试:

    python3 build/bin/llvm-lit -v llvm-project/llvm/test/Transforms/InstCombine/add.ll

    它没有进入用例执行,先报缺少 build/bin/count。这不是 add.ll 检查发现优化错误,而是测试配置需要的辅助工具尚未生成。LLVM_INCLUDE_TESTS=ON也不代表只构建 opt 和 FileCheck 就自动补齐所有测试依赖。

    针对这个缺项,可继续补建测试辅助工具后重试:

    cmake --build build --target count not --parallel 2
    python3 build/bin/llvm-lit -v llvm-project/llvm/test/Transforms/InstCombine/add.ll

    这两行是补齐缺项后的继续步骤,本次没有将其登记为通过:原临时构建目录在后续会话中已不可用,未为补记一个成功结果重新进行完整构建。原始构建成功日志与 lit 失败日志均保留。

    为了单独核对用例内容,我另取同一固定提交的 add.ll,使用系统已有的 Ubuntu LLVM18.1.3工具,直接执行其 RUN 行对应的操作:

    set -o pipefail
    opt-18 < add.ll -passes=instcombine -S | FileCheck-18 add.ll

    这条管道本次通过,opt 与 FileCheck 的独立检查均成功。它是发行包工具对一个官方测试文件的检查,不是刚才源码构建二进制的 lit 通过,更不是完整 LLVM 测试套件通过。两条记录要分别保留。

    从名字定位到实现和用例

    以下路径已按上面的提交核对。先看 opt.cpp和 NewPMDriver.cpp,了解工具怎样进入新Pass管理器;然后在 PassRegistry.def查找字符串 instcombine。

    继续找到 InstructionCombining.cpp中的 InstCombinePass::run。加减法相关处理可从 InstCombineAddSub.cpp中的 InstCombinerImpl::visitAdd进入。这里给的是入口,不是说所有加零折叠都只发生在这一个函数里;公共简化逻辑也会参与。

    rg -n '"instcombine"' llvm-project/llvm/lib/Passes
    rg -n 'InstCombinePass::run' llvm-project/llvm/lib/Transforms/InstCombine
    rg -n 'visitAdd' llvm-project/llvm/lib/Transforms/InstCombine
    rg -n '^; RUN:|^; CHECK' llvm-project/llvm/test/Transforms/InstCombine/add.ll

    最后回到 add.ll。RUN 指定工具命令,%s 在测试系统中指当前测试文件;CHECK 一类指令描述输出应该出现的模式。FileCheck 验证这些模式,不执行被优化的程序,也不代替语义正确性证明。本例的源码、命令和预期通过这一文件连起来。

    先缩小输入,再加调试信息

    调试从保存问题开始:固定提交、完整命令、最小输入、预期和实际输出。若问题是“某个 Pass 没运行”,先看调度日志;若是某一步生成非法 IR,先在流水线中加入验证;只有这些证据还不够,再进入源码断点。

    opt-18 -passes=instcombine -debug-pass-manager -verify-each \
      -S input.ll -o output.ll

    这里的 input.ll 应替换成你保存的最小复现文件。源码构建环境保留完整时,再将命令开头换成对应 build/bin/opt,确保调试的二进制与正在阅读的提交一致。不要一边读主分支源码,一边用系统另一版本的工具推断具体调用路径。

    本期建立的是一条可追溯的阅读路线:工具入口、Pass注册、实现入口、回归预期。今后定位设备专用变换或兼容问题,也应先找清这几项关系,而不是从整个仓库规模开始。

    构建身份为什么必须同时包括源码与配置

    同一提交不等于同一二进制。目标集合、断言、构建类型、可选项目、宿主编译器和依赖,都可能影响可用功能与诊断。只写“使用LLVM18”无法让另一位读者判断正在调试哪个实现。

    建议把源码提交、CMakeCache中的关键配置、实际工具版本、二进制身份与运行命令连在同一份记录里。源码树与构建树分离,可以让不同配置共存;调试前必须确认命令实际启动了想要的build/bin/opt,而非PATH里的发行包工具。

    Release+assertions启用内部一致性检查,但不自动提供完整调试信息,也不意味着被优化掉的局部变量可以按源码直觉观察。切换Debug或RelWithDebInfo是另一个构建条件,应记录资源成本与差异,而不偷偷把不同二进制的结果合并成同一次实验。

    怎样读一份回归测试,而不把FileCheck当解释器

    RUN行说明执行什么命令,CHECK类指令说明工具输出应满足哪些文本模式。一个测试可用于防止某种代码形状回归,但它不必运行被编译程序,也未必覆盖业务语义的全部输入。模式匹配通过与程序正确是不同命题。

    检查时要看模式是否过弱或过强:只要求出现add,可能遗漏错误操作数;死盯无关编号与排列,又可能把无害打印变化当成失败。CHECK-NOT等否定条件也有受前后匹配范围约束的语义,不能凭字面猜覆盖了整份输出。细节见FileCheck参考。

    测试框架配置失败,还没有进入这类预期检查。本篇缺count的lit失败、源码构建工具成功、发行包工具直接跑RUN管道成功,分别证明不同事情。补建count/not后重跑仍是待办;临时目录后来不可用的原因也未确认。LLVM测试基础设施说明可帮助恢复完整条件,不能替代本机运行记录。

    2026-10-18内容修订:深化构建身份与测试判据,保持lit未通过及无调试器会话事实。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源、验证范围与阅读路线

    源码固定为18.1.3提交;版本化文档为18.1.8。实际结果:源码构建opt/FileCheck与版本检查成功;源码构建环境的lit因缺count未运行用例;发行包工具直接检查同一add.ll通过。未运行全套回归、断点调试或性能测试。

    上一篇 · 文章归档 · 知识体系

    勘误:2026-10-07,初版。构建与测试按不同状态登记,后续补验应附配置与日志。

  • LLVM Pass 是什么?优化流水线如何组织

    作者:lihao技术文章发布:LLVM 与异构编译器 · 第 09 期

    把同一份 IR 依次交给 mem2reg 和 instcombine,观察分析与变换、流水线层级和验证日志,并排查 opt 没有改变函数的原因。

    把一次改写变成可单独观察的步骤

    看到 O0 和 O2 的 IR 不同,只能说明两套编译配置产生了不同结果。要继续问“哪一步改了它”,就需要把过程拆开。LLVM Pass 是组织分析和变换的工作单元;流水线决定这些工作在什么范围、按什么顺序执行。

    不是所有 Pass 都负责把程序改短。分析可以计算支配关系等信息;变换则可能改写 IR。一个变换也不保证每次都改变输入,更不保证每次都让真实程序更快。本文只用两个明确的 Pass,避免从一整套 O2 输出倒猜原因。

    先固定同一份输入

    实验环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3,GNU binutils 2.42。

    把下面这份手写 IR 保存为 pipeline.ll。它没有从某个 C++ 优化级别隐式带来函数属性,适合单独观察 Pass:

    define i32 @twice(i32 %x) {
    entry:
      %slot = alloca i32, align 4
      store i32 %x, ptr %slot, align 4
      %v = load i32, ptr %slot, align 4
      %plus = add i32 %v, 0
      %result = mul i32 %plus, 2
      ret i32 %result
    }

    这段计算先把 x 存入栈槽,再读出,加零、乘二并返回。这里刻意保留可消除的操作,不代表普通程序一定会生成这样的 IR。无额外溢出标志的 i32 算术按相应位宽处理,测试端用 unsigned 对照。

    先mem2reg,再instcombine

    opt-18 -passes=mem2reg -verify-each -S pipeline.ll -o promoted.ll

    本次输出的函数主体为:

    entry:
      %plus = add i32 %x, 0
      %result = mul i32 %plus, 2
      ret i32 %result

    栈槽及其读写消失了,但加零和乘二仍在。继续运行:

    opt-18 -passes=instcombine -verify-each -S promoted.ll -o combined.ll

    本次结果变为:

    entry:
      %result = shl i32 %x, 1
      ret i32 %result

    现在可以把观察说得具体:在固定输入和版本下,mem2reg 提升了这个栈槽;随后 instcombine 消除了加零,并把乘二整理为左移。不能由此说“所有乘法都更慢”,也不能据 IR 指令数量推断最终执行时间。

    把步骤放进一条明确的流水线

    opt-18 '-passes=function(mem2reg,instcombine)' \
      -debug-pass-manager -verify-each -S pipeline.ll -o transformed.ll

    function(…) 指出这一组工作面向函数。Pass 管理器还组织模块、调用图相关结构和循环等层级;跨层级需要适配,不能把任意名称无条件排成一串。

    -debug-pass-manager把实际调度写到诊断输出中;-verify-each在中间步骤加入验证。这样出现非法 IR 时,可以比“最后一个文件坏了”更早定位。验证器仍不是程序正确性证明,不会替你知道业务上应当乘二还是乘三。

    opt-18 '-passes=default<O2>' -print-pipeline-passes -disable-output pipeline.ll

    这条命令本次打印了当前版本的 O2 默认流水线。它适合查当前配置,不适合作为永远不变的顺序表。LLVM 版本、目标条件和构建的工具组件都可能影响可用 Pass 与默认安排。重复运行某些工作也可能有用,因为前一步会暴露新的机会。

    分析结果为什么需要管理

    opt-18 '-passes=print<domtree>' -disable-output pipeline.ll

    本次命令打印了该函数的支配树信息。它回答的是图上的关系,没有要求生成新的目标程序。分析结果可以被后续变换使用;当变换改变了它依赖的结构,缓存就可能失效,必须正确保留、更新或重算。

    因此 Pass 接口不只是“传进 IR、传出 IR”。它也要交代修改之后哪些分析仍然可信。这个契约对生态接入同样关键:加入一个设备相关 Pass,如果错误地保留过期分析,表面上省了重算,实际会破坏后续判断。

    opt没有改变函数,先看它有没有运行

    我们还做了一个对照:用 Clang 默认 O0 生成 IR,再尝试 instcombine。该函数带有 optnone,诊断显示 InstCombinePass 被跳过。原因是输入属性影响了是否运行,不是命令退出 0 就必然做了优化。

    clang++-18 -O0 -S -emit-llvm twice.cpp -o optnone.ll
    opt-18 -passes=instcombine -debug-pass-manager -S optnone.ll -o skipped.ll

    这里的 twice.cpp 仍是前面系列使用的 unsigned 乘二加零函数。排查时依次看:命令是否成功;Pass 是否进入;函数属性是否要求跳过;输入是否存在该变换能够处理的模式。没有变化还可能只是已经达到该 Pass 的稳定形式。

    需要教学对照时,本次在 Clang18 使用 -Xclang -disable-O0-optnone重新生成输入,再运行 mem2reg、instcombine,实际得到了左移。这个内部选项有版本边界;不要机械删除所有函数属性。它们有的承载语言或目标语义,随意删除会让比较失去依据。

    检查这次输出有没有算对

    为了核对运行结果,本次将变换后的 IR 与下面的入口链接。保存为 main.cpp:

    #include <cstdio>
    extern "C" unsigned twice(unsigned);
    int main() {
      for (unsigned x=0; x<1000u; ++x) if (twice(x) != x*2u) return 1;
      if (twice(~0u) != ~0u*2u) return 2;
      std::puts("1001 cases passed");
    }
    clang++-18 transformed.ll main.cpp -o demo
    ./demo

    本次 1000 个连续输入及 UINT_MAX,共 1001 组检查通过。有限测试覆盖的是这些输入,不是数学证明;本期也没有性能对比。测试失败时,应先保留原始 IR、流水线、版本与失败输入,而不是继续叠加 Pass 碰运气。

    把输入固定、把步骤拆开、把失败保留下来,才能从“好像优化了”走到可核对的判断。下一篇沿着同一个 instcombine 入口进入 llvm-project,找到实现与回归测试的位置。

    如果你正遇到“命令成功但 IR 没变”,可单独阅读知乎排查篇:为什么 opt 跑完以后,LLVM IR 一点没变?

    为什么Pass次序会影响机会,而不仅影响输出长相

    mem2reg把可提升栈槽上的读写转为直接值关系,使后续局部化简更容易看到“某值加零再乘二”。如果先运行只处理显式值模式的变换,可能因为输入仍经由内存间接连接而错过机会。前置变换改变了可见结构,而不只是美化文本。

    但这不推出任何固定顺序都最优。某次内联可能暴露常量与循环,也可能增大代码和寄存器压力;一次规范化可能让一个Pass获益,却令另一个模式匹配不再触发。流水线要在编译时间、目标代价和运行表现之间评价。

    对“为什么这一步没有改变”的调查,应分别检查运行与匹配:Pass是否被调度,函数是否被属性要求跳过,前提是否满足,输入是否已规范化,变换是否判断收益不足。退出码0只说明命令按工具定义完成,不保证任何具体改写。

    分析缓存的正确性,也是变换契约的一部分

    一个Pass若改变CFG,原有支配树或循环分析可能不再正确。后续Pass若继续消费过期结果,错误可能在更晚阶段才出现。局部输出看似合法,不意味着整条管线的内部状态可靠。

    新Pass管理器让变换报告哪些分析被保留;这是关于依赖关系的声明,必须与实际修改一致。报告全部保留但未维护相关分析,是错误;一律丢弃虽可能避免使用旧结果,也会产生重算成本,且不能替代变换本身的语义正确性。

    LLVM 18.1新Pass管理器文档描述分析管理与失效。阅读某个实际Pass时,应将run的改写、返回的PreservedAnalyses与相关分析使用一并检查。

    本例的1001组测试只对所取unsigned输入核对结果。若要提升覆盖,可加入系统边界、随机差分与缩减失败输入;若要做性能结论,则需另设目标负载与测量。本轮没有把这些建议登记成已执行。

    2026-10-18内容修订:补Pass机会、失效契约与测试证明力。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源、验证范围与阅读路线

    本期实测采用18.1.3;下列版本化文档来自18.1系列的18.1.8文档站,命令输出以本文实测为准。持续更新的官方页面用于补充原理,不据此改写旧版运行结果。

    同一手写IR:mem2reg去栈槽,instcombine变shl;pipeline调度与domtree已记录;O0的optnone导致InstCombine跳过;1001组检查通过。未知Pass预期失败。没有性能结论。

    上一篇 · 下一篇:第一次阅读 llvm-project:源码目录、构建与调试入口 · 文章归档 · 知识体系

    勘误:2026-10-07,初版。发现问题时请附工具版本、命令与关键输出;确认后在此记录修改。

  • 用一段分支程序理解 BasicBlock、CFG 与 SSA

    作者:lihao技术文章发布:LLVM 与异构编译器 · 第 08 期

    从一段 if/else 程序生成并检查 LLVM IR,跟踪基本块、控制流边和 phi 的取值,再对照六组运行结果理解 SSA。

    两条路径上的y,怎样合到一起

    上一期的函数只有一条计算路径。现在加一个 if/else:两个分支给同一个变量 y 赋不同的值,函数出口应该返回哪一个?C++ 的写法很熟悉,但编译器还需要把“走了哪条路”和“值从哪里来”表示清楚。

    extern "C" unsigned choose(unsigned x, bool take_left) {
      unsigned y;
      if (take_left) y = x + 1u;
      else y = x + 2u;
      return y;
    }

    这份代码没有循环,也没有复杂指针,正好用来分开理解三个概念:BasicBlock组织直线代码,CFG记录路径,SSA记录值的定义与使用。

    生成适合观察分支的IR

    实验环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3,GNU binutils 2.42。

    clang++-18 -std=c++17 -O0 -Xclang -disable-O0-optnone   -fno-discard-value-names -S -emit-llvm choose.cpp -o raw.ll
    opt-18 -passes=mem2reg -verify-each -S raw.ll -o ssa.ll

    这里先让 Clang 保留容易辨认的分支,再运行 mem2reg,把适合提升的栈槽读写整理成 SSA 值。-disable-O0-optnone是本次 Clang18 教学实验使用的内部选项,用来避免默认 O0 属性阻止后续优化,不作为跨版本稳定接口。

    raw.ll 本来就使用 SSA 值。mem2reg 的作用不是让一个“完全没有 SSA 的语言”突然变成 SSA,而是把符合条件的内存读写提升到值层面。不是每一个 alloca 都能提升。

    四个基本块,四个清楚的出口

    本次 ssa.ll 的函数主体如下;省略函数属性、模块头和元数据,保留实际块名与指令:

    entry:
      %frombool = zext i1 %take_left to i8
      %tobool = trunc i8 %frombool to i1
      br i1 %tobool, label %if.then, label %if.else
    
    if.then:
      %add = add i32 %x, 1
      br label %if.end
    
    if.else:
      %add1 = add i32 %x, 2
      br label %if.end
    
    if.end:
      %y.0 = phi i32 [ %add, %if.then ], [ %add1, %if.else ]
      ret i32 %y.0

    从每个标签往下读,到终结指令为止,就是这里的一个基本块。entry 的条件分支决定去哪个后继;if.then 和 if.else 各做一次加法,再跳到 if.end;最后一个块返回。终结指令不只有分支,ret 也会结束基本块。

    CFG只保留控制流关系

                 entry
              true / \ false
                  /   \
            if.then   if.else
                  \   /
                  if.end
                    ret

    把基本块作为节点,把可能的控制转移作为边,就得到控制流图 CFG。图上的两条分支边表示可选路径,不表示一次调用会同时执行两边。这个函数在每次调用中只经过一个加法分支。

    如果需要工具输出,本次也执行了下面的命令,生成 DOT 文件供检查。它不会自动完成图片渲染;本文的文字图依据实际块关系整理。

    opt-18 -passes=dot-cfg -disable-output ssa.ll

    SSA中的单次赋值,约束的是值名

    源码里的 y 可以在两个分支中赋值。整理后的 IR 不再让同一个值名被反复赋值:%add、%add1 和 %y.0 各有自己的定义,后面的使用可以追溯到定义处。这里的“单次”说的是 IR 中的定义形式,不意味着一条指令在带循环的程序里只会动态执行一次。

    内存也没有因此变成只读。store 仍可改变内存;指针、别名和副作用需要额外分析。不能把 SSA 理解成“整个程序中所有变量都不可变”。本例中的栈槽被提升,是因为这些特定用法满足提升条件。

    phi根据刚才经过的边取值

    %y.0 = phi i32 [ %add, %if.then ], [ %add1, %if.else ]

    这行要和进入 if.end 的路径一起读:刚从 if.then 过来,就取 %add;刚从 if.else 过来,就取 %add1。方括号里是值和前驱块的配对,不是把两个结果相加,也不是重新计算两个分支。

    输入实际路径phi得到的值
    x=7,take_left=trueentry → if.then → if.end8
    x=7,take_left=falseentry → if.else → if.end9

    phi 位于块开头;理解它时,先确认前驱边,再看对应值。本例没有多重边和循环回边,这两个复杂情况留给后续专门处理。

    把IR重新编译,再检查结果

    保存下面的 main.cpp:

    #include <cstdio>
    #include <climits>
    #include <initializer_list>
    extern "C" unsigned choose(unsigned, bool);
    int main() {
      for (unsigned x : {0u, 7u, UINT_MAX}) {
        for (bool left : {false, true}) {
          unsigned want = x + (left ? 1u : 2u);
          unsigned got = choose(x, left);
          std::printf("x=%u left=%d got=%u expected=%u\n", x, left, got, want);
          if (got != want) return 1;
        }
      }
    }
    clang++-18 ssa.ll main.cpp -o demo
    ./demo
    printf 'exit=%s\n' "$?"

    本次六组输入全部符合预期,程序退出 0。UINT_MAX 分别加一、加二得到 0 和 1,这与本例无符号回绕一致。六组测试不是全输入证明,但能检查两条路径和一个明确的边界输入;类型与结构检查则由前面的验证器负责。

    优化以后,分支为什么可能消失

    clang++-18 -O1 -S -emit-llvm choose.cpp -o optimized.ll

    本次 O1 输出的核心变成:

    %3 = select i1 %1, i32 1, i32 2
    %4 = add i32 %3, %0
    ret i32 %4

    现在先按条件选择 1 或 2,再做加法。这说明源代码的 if/else 不保证最终 IR 一定保留相同的 CFG。这里的表达式没有需要保留的分支副作用;不能把这个结果推广成任何含函数调用或内存副作用的分支都可以无条件合并。

    select 是 IR 的值选择指令,也不保证最后一定生成某一条硬件条件选择指令。想知道机器怎么执行,还要继续观察目标代码。本期只建立从路径到值的对应关系,没有测量分支预测或运行性能。

    支配关系怎样解释SSA中的“定义在使用之前”

    直线代码中,定义排在使用前面比较直观;有分支时,文本位置在前并不保证每条路径都执行过定义。若从入口到某个使用点的每条路径都经过定义点,我们说定义点支配该使用点。SSA需要这种路径意义上的可用性。

    在本例里,if.then中的%add不支配if.end中的普通指令,因为从if.else也可到达出口。phi把使用关联到对应前驱边:只有从if.then进入时才选%add,从另一条边进入时选%add1。它不是允许任意读取未执行分支的结果。

    循环会让静态单一定义与动态多次执行同时出现。某个phi可以接收入口初值与回边更新值,表示每轮的新状态;名字在IR里只定义一次,并不意味着运行时只产生一次值。循环SSA需要同时追踪回边与支配关系,不能按文本从上到下解释全部执行。

    为什么把分支改成select,不等于无条件执行两边

    select选择已经可用的值,不是控制流指令。把含除法、可能陷阱的操作或有副作用的调用从分支搬到公共位置,可能让原本不会执行的运算被执行,因而改变行为。是否合法取决于被移动操作与IR语义,不能只看最终返回值相似。

    本例两个分支只是无符号加法,O1把选择增量与加法组合起来,证据范围很清楚。若换成读写设备、volatile访问或不满足前提的除法,就不能照抄这一变换。poison在不同使用位置的传播也需要按具体指令规则检查。

    后端还会把phi降低为适当的拷贝与寄存器约束,而不必生成一条叫phi的机器指令。CFG的语义、IR的值关系与硬件实现是相连的三个层次。可参照LLVM控制流教程继续核对phi与前驱的关系;本文六组运行不扩写为全输入或硬件分支性能证明。

    2026-10-18内容修订:补支配、循环动态执行与投机执行边界。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源、验证范围与阅读路线

    本期实测采用18.1.3;下列版本化文档来自18.1系列的18.1.8文档站,命令输出以本文实测为准。持续更新的官方页面用于补充原理,不据此改写旧版运行结果。

    实际四块CFG与phi已读取;六组运行(0、7、UINT_MAX各两条路径)全部通过。O1实际输出select再add。AArch64无关,不添加性能或硬件分支结论。

    上一篇 · 下一篇:LLVM Pass 是什么?优化流水线如何组织 · 文章归档 · 知识体系

    勘误:2026-10-07,初版。发现问题时请附工具版本、命令与关键输出;确认后在此记录修改。

  • LLVM IR 到底是什么?为什么需要中间表示

    作者:lihao技术文章发布:LLVM 与异构编译器 · 第 07 期

    用一个 unsigned 函数观察 LLVM IR 的类型、值和目标信息,实测文本与 bitcode 转换、验证器和错误输入,说明 IR 提供的复用边界。

    IR解决的是共同表示问题

    把 C++ 直接翻译成某种机器码,当然可以。但如果每一种语言都各自实现一套优化和每一种目标的代码生成,重复工作会很快增加。中间表示给这些工作提供了一个共同接口:先把语言结构转换成有明确语义的表示,再在其上分析、变换并生成目标代码。

    在 LLVM 中,常见的 LLVM IR 可以存在于内存对象、可读文本和 bitcode 中。它既不是原源码的逐字翻译,也不是某台 CPU 的机器指令。理解 IR 的重点,是读出计算、控制流和约束,而不是把每个符号都翻译回 C++。

    先生成一份足够小的IR

    实验环境:Ubuntu 24.04.4(WSL2,x86-64),Clang/LLVM 18.1.3,GNU binutils 2.42。

    保存 twice.cpp:

    extern "C" unsigned twice(unsigned x) { return x * 2u + 0u; }
    clang++-18 -std=c++17 -O1 -S -emit-llvm twice.cpp -o twice.ll
    cat twice.ll

    本次函数部分如下。这里只节选函数,完整文件还包含目标信息、属性定义和元数据:

    define dso_local noundef i32 @twice(i32 noundef %0) local_unnamed_addr #0 {
      %2 = shl i32 %0, 1
      ret i32 %2
    }

    先读计算:函数接收一个 32 位整数值,左移一位,再返回这个结果。原源码中的“加零”没有出现在这份 O1 输出里。这是本次实际输出,不意味着 IR 必须永远只有两条指令;换成 O0,可能先看到 alloca、store 和 load。

    从类型、值和指令开始读

    片段在这个例子里的含义
    @twice全局函数符号
    i3232位整数类型;单靠它不区分有符号与无符号
    %0、%2局部值的编号,不是物理寄存器号
    shl i32 %0, 1把该整数值左移一位
    ret i32 %2返回刚得到的值
    #0引用文件后面的属性组,阅读时不能忽略

    例如有符号与无符号除法使用不同指令,某些算术指令还可以带不溢出等标志。不能因为 C++ 里写了 unsigned,就猜所有相关语义都藏在 i32 这个词里。也不要手动给指令补上看起来“更利于优化”的标志:它们会改变允许假定的条件。

    本例选择无符号乘二:在本机 unsigned 为 32 位的条件下,它的回绕与这条无额外溢出标志的左移相符。这个具体例子容易核对,不足以证明任意有符号运算、浮点表达式或向量运算都可以照样替换。

    文本和bitcode:内容表示可以切换

    llvm-as-18 twice.ll -o twice.bc
    llvm-dis-18 twice.bc -o roundtrip.ll
    file twice.ll twice.bc
    opt-18 -passes=verify -disable-output twice.ll

    这四步本次均成功:file 将 twice.bc 识别为 LLVM IR bitcode,验证器接受 twice.ll。llvm-as / llvm-dis 改变的是保存方式,不是执行函数,也不是把它变成目标汇编。文本往返后,文件标识、注释或打印形式可能变化;不能只靠逐字相同判断语义。

    版本也要记录。遇到无法读取的 bitcode,先核对生成与读取工具的版本和兼容规则。把文件改名为 .ll 不会把二进制编码转换成文本。

    验证通过能证明到哪一步

    保存下面这份故意写错的 bad.ll:

    define i32 @bad(i32 %x) {
    entry:
      ret i64 %x
    }
    llvm-as-18 bad.ll -o bad.bc

    本次工具拒绝该输入:%x 定义为 i32,却被当成 i64 返回。这个检查比“看起来像 IR”严格得多,但仍不能证明程序算出了我们想要的答案。把乘二误写为乘三也可能是合法 IR,必须再靠语义审查和运行测试发现。

    因此至少分开问三件事:文件能否读取;IR结构与类型是否满足规则;生成程序是否满足要求。性能还要另测,不能从“验证通过”顺便推断。

    有中间层,不等于与目标完全无关

    clang++-18 --target=aarch64-linux-gnu -O1 -S -emit-llvm twice.cpp -o aarch64.ll

    对这个没有头文件依赖的小函数,本次成功生成了 AArch64 IR。比较两个完整文件可以看到 target triple、data layout 和属性等差异。目标信息影响类型布局、对齐、调用约定与目标相关操作的解释。这里没有运行 AArch64 程序,也没有完成带系统库的交叉链接。

    这解释了一个常见误解:不能只改 target triple,就宣称某份 IR 可以安全迁往任意硬件。真正的接入还要核对语言语义、地址空间、运行库与 ABI;设备专用 intrinsic 也可能形成额外依赖。IR提供可复用的接口,接口本身仍有契约。

    下一步该观察什么

    面对一份陌生 IR,先找函数的输入和返回,再跟踪一个值如何产生、如何被使用,最后补读属性与目标信息。这个函数没有分支,所以沿两条指令就能读完;下一篇增加 if/else,看同一个源级变量怎样跨路径表达。

    定义良好、行为等价与目标合法,是三道不同检查

    考虑两个函数都接收i32并返回i32,一个计算x+1,另一个计算x+2。二者都可能满足IR结构与类型规则,但并不实现同一函数。验证器能拒绝类型不配,却不知道用户希望加几。

    再看算术标志。无附加标志的i32加法按位宽形成相应结果;nsw或nuw增加不发生相应溢出的约束,违背时会产生poison。它们不是“请编译器更努力优化”的提示,而是会参与语义推导的承诺。

    一个简单IR变换因此至少要回答:输入在哪个域定义良好,输出是否保留允许行为,属性是否仍满足,目标能否降低对应操作。验证通过只覆盖其中结构性部分,差分测试又只覆盖被选的输入,二者应互补而非争夺一个“已正确”标签。

    相关具体语义可查LLVM 18.1的poison说明与各指令章节。本文不把教学函数省略的属性当成可在真实代码中任意删除的内容。

    IR的抽象层次,决定哪些事实必须显式保留

    源级局部变量可能消失为值,类与异常可能降低为调用、控制流和运行时协议;反过来,IR仍保留类型、内存、副作用和目标约束。它不是源码备份,也不是抹掉语义后只剩计算图。

    在不透明指针表示下,ptr本身不携带被指向元素类型,不意味着load、store、getelementptr等操作失去类型与布局规则。读指针程序时要沿使用位置恢复对象大小、对齐和地址计算依据,不能只看ptr三个字母。

    对于目标相关模块,随手改triple与data layout可能让同一条地址计算具有不同布局含义。若要跨目标复用,必须核对哪些操作、属性、运行库与ABI依赖原目标。把一个无头文件小函数生成另一目标IR,与完整工程移植,是不同证据等级。

    2026-10-18内容修订:补语义/验证/合法性分工及不透明指针阅读要点。本次为文献与语义审校,未新增实验;首次发布日期、原网址及历史运行版本保持。

    来源、验证范围与阅读路线

    本期实测采用18.1.3;下列版本化文档来自18.1系列的18.1.8文档站,命令输出以本文实测为准。持续更新的官方页面用于补充原理,不据此改写旧版运行结果。

    LLVM18.1.3实测文本/bitcode往返、verify均通过;错误类型IR被拒绝;AArch64仅IR生成。展示函数来自原始文件,省略模块头和属性组有明确标记。

    上一篇 · 下一篇:用一段分支程序理解 BasicBlock、CFG 与 SSA · 文章归档 · 知识体系

    勘误:2026-10-07,初版。发现问题时请附工具版本、命令与关键输出;确认后在此记录修改。