
1. 先把这两个写法彻底拆开看说句实在话C 语言里cnt这种普通变量和*(unsigned long *)、(unsigned long)这两串东西放在一起十有八九是要出事的前兆。我第一次在别人的代码里看到*(unsigned long *)cnt的时候第一反应是这人是不是想取地址取错了第二反应是这代码在 32 位机器上竟然跑通了。后来自己啃了一堆寄存器手册和编译器手册才明白这两个写法压根不是一回事一个是值转换一个是内存重解释中间隔着类型系统、对齐规则、别名规则三道墙。很多人把它们当成两种都能转成 unsigned long 的办法这就是所有诡异 bug 的源头。这篇东西适合两类人看一类是在嵌入式或者驱动代码里天天写*(volatile unsigned long *)0x40021000的人另一类是在应用层代码里看到了这行写法、想搞清楚它到底安不安全的 C 语言学习者。我不是来讲语法的语法书上写得很清楚我想讲的是为什么同样的写法放在寄存器地址上就是标准操作放在普通变量cnt上就是埋雷以及真出问题的时候怎么把它揪出来。1.1 值转换和解引用转换的本质差异先把这个最基本的分界线画清楚。(unsigned long)cnt干的事情是读出cnt的值然后按照 C 的转换规则把这个值换算成unsigned long类型的值。整个过程中cnt所在的那块内存没有被重新解释编译器只是拿到一个数值做了一次算术意义上的转换。如果cnt是int类型的-1在 32 位平台上转换结果是0xFFFFFFFF转换规则是模 2^32 取余如果cnt是char类型的A结果是65。这一路都是明明白白、有标准条文兜底的。而*(unsigned long *)cnt干的事情完全不同它分三步第一步取cnt的地址第二步把这个地址强行当作unsigned long *来解释第三步从这个新类型的指针上解引用读走sizeof(unsigned long)个字节。注意关键点第三步读多少字节是由unsigned long的宽度决定的跟cnt是什么类型毫无关系。在典型的 64 位 Linux 环境里unsigned long是 8 字节如果cnt是 4 字节的int这一读就是读越界把cnt后面 4 个字节的栈内容也一起卷进来了。我见过有人辩解说我读出来低 4 位是对的就行。这话在小端机器上确实经常成立因为低地址的数据落在低位cnt的值不会被高位垃圾污染。但这是结果凑巧正确不是代码正确。换成大端平台、换成开优化、换成编译器做了寄存器分配结论全变。1.2 那个cnt普通变量到底冤在哪标题里那个cnt我理解为就是一个普普通通的int或者long计数器可能是统计循环次数、统计中断次数、统计收包数量总之是个有明确类型的、住在栈上或者静态区的普通变量。它跟硬件寄存器最大的区别在于寄存器地址是外部约定的物理地址那块地址上的数据宽度和解释方式由芯片手册规定死了C 语言编译器看不到也管不着所以你必须用指针强转去戳它而普通变量cnt的类型是编译器自己维护的编译器知道它是 4 字节int知道它怎么被访问的知道它有没有被取过地址。这就导致一个很有意思的现象你对着普通变量写*(unsigned long *)cnt编译器在类型层面认为你在做一件完全合法的事把int *转成unsigned long *在语法上是允许的但在语义层面你已经踩进了未定义行为的区间。更麻烦的是编译器一旦发现cnt的地址被逃逸出去了它可能就不再把这个变量放进寄存器优化性能顺手掉一截你还以为是自己写的算法慢。提示判断一段代码是必须用指针强转还是用值转换就行只看一件事——你操作的对象是内存映射的外部资源还是C 语言自己管理的变量。前者必须强转后者一律用值转换。2. 为什么*(unsigned long *)读得动寄存器却读不了普通变量2.1 硬件寄存器的访问路径长什么样嵌入式圈子里*(volatile unsigned long *)0x40021000 0x01;这种写法几乎是肌肉记忆它背后的逻辑很硬芯片手册告诉你地址0x40021000上挂着一个 32 位的控制寄存器写 1 就能使能某个外设时钟。CPU 访问这个地址时总线会把这个请求路由到对应的外设模块而不是内存条。C 语言里没有任何寄存器这个类型只有指针和整数所以唯一的表达方式就是把整数当地址、转成指针、解引用。这中间volatile是必须加的否则编译器可能觉得你写了两次同样的值第二次多余把写操作优化掉硬件那边就永远收不到第二次触发。这个场景下指针强转是唯一正确解不存在更优雅的替代方案。C 标准里volatile的语义保证了每次访问都真实发生unsigned long保证了访问宽度跟寄存器宽度匹配32 位平台下unsigned long是 4 字节正好对应 32 位寄存器这也是老代码偏爱unsigned long而不是unsigned int的原因之一。2.2 换到普通变量上风险一下变成三重同样是这个语法作用在cnt上性质就完全变了。我把风险归纳成三条实际排查时基本都能对上号。第一条是宽度不匹配导致越界读。sizeof(unsigned long)在不同平台上是 4 或 8而cnt的宽度是 4只要前者大于后者就是读越界。越界读在栈上通常不会立刻崩读出来的是相邻变量的内容或者对齐填充的垃圾这种 bug 最阴险因为它的表现是数值偶尔不对而不是程序崩溃。第二条是对齐问题。unsigned long *要求地址按 8 字节64 位平台对齐而cnt作为一个int可能只按 4 字节对齐。在 x86 上不对齐访问硬件层面是支持的只是慢一点在很多 ARM 平台上不对齐的 8 字节访问会直接触发异常或者悄悄读错数据。我实测过在某个 ARM 开发板上把int地址强转成unsigned long *去读结果是低 4 字节正确、高 4 字节全零看起来能用但那是运气。第三条是违反严格别名规则。C 标准规定通过一个与对象有效类型不兼容的左值去访问该对象是未定义行为。int和unsigned long是不兼容类型标准里给的例外只有char、signed char、unsigned char这一族可以别名任何对象。所以*(unsigned long *)cnt从标准角度就是 UB编译器在-O2下完全有理由按 UB 来优化比如认为这次读取和cnt的写入没有关系把读取结果直接常量折叠掉。2.3 用一段对照代码把差异摆出来光说理论没感觉直接上代码。下面这段在 64 位 Linux 上用gcc -O0和gcc -O2各跑一次观察结果差异。#include stdio.h int main(void) { int cnt 0x12345678; unsigned long by_value (unsigned long)cnt; /* 值转换安全 */ unsigned long by_ptr *(unsigned long *)cnt; /* 重解释危险 */ printf(cnt %#x\n, cnt); printf(by_value %#lx\n, by_value); printf(by_ptr %#lx\n, by_ptr); return 0; }-O0下by_ptr通常会打印成0x00007fff12345678这个形状高位是栈地址残留低位正好是cnt的值看起来能用。换到-O2如果cnt没有被取地址逃逸有些编译器优化后仍然保留取地址语义结果可能变成0x12345678高位被清零也可能维持原样。这种编译选项一改结果就变的表现就是 UB 的典型特征。真正想安全地把cnt的字节按unsigned long解释正确做法是memcpy或者联合体我在第 5 节会给出可以直接抄的写法。3. 类型系统、别名规则与对齐这三道墙3.1 严格别名规则到底怎么坑人严格别名strict aliasing这条规则被很多人当成玄学其实它的出发点很朴素编译器要相信不同类型的指针指向不同对象这样才能放心做寄存器缓存和重排。比如函数里传进来一个int *和一个float *编译器默认它们不重叠可以分别加载到寄存器里反复用如果它们实际上指向同一块内存那缓存就过期了。-fstrict-aliasing在-O2及以上是默认开启的这也是为什么很多人写*(float *)int_var这种快速类型转换在 debug 版本好好的一上 release 就数值乱飞。想验证自己的代码有没有踩这个坑最快的方法是加-fno-strict-aliasing再编一遍如果结果变了基本就实锤了。访问方式标准定性典型后果同类型指针解引用合法无char */unsigned char *访问任意对象合法无unsigned long *访问int对象未定义行为数值异常、被优化掉联合体成员交叉读写实现定义C99 起常见做法通常可用需注意字节序memcpy做字节搬运合法无现代编译器会内联成单条指令表里最后一行特别值得说一句memcpy做小尺寸拷贝时现代编译器基本都会把它优化成一条寄存器加载指令性能跟直接强转没有区别但语义完全合法。所以为了性能必须强转这个理由在新的编译器上已经不成立了。3.2 对齐与越界的实测记录我拿一块 ARM Cortex-M 的板子做过一组对照实验把char数组的不同偏移位置强转成unsigned long *去读观察是否触发硬件异常。结果很能说明问题偏移 0、4、8 这些 4 字节对齐的位置读 8 字节没问题因为 M 系列的unsigned long是 4 字节这里实际是 4 字节访问但如果换成 8 字节的long long或者double偏移 4、12 这种没按 8 字节对齐的位置读会直接进 HardFault。x86 平台宽容得多不对齐访问最多是性能损失程序照样跑。这种开发机没问题、目标板一上就崩的情况是嵌入式移植时最消耗时间的一类问题。我的经验是任何涉及指针强转的地址先在纸上算一遍对齐地址除以目标类型宽度能不能整除不能整除就得换方案别指望硬件帮你兜底。注意malloc返回的地址保证对任何基本类型对齐但数组内部的偏移、结构体成员、网络包缓冲区里的字段统统不保证。协议解析场景里(unsigned long *)(buf 2)这种写法 100% 有对齐隐患。4. 指针转整数(unsigned long)ptr的适用场景4.1 这一侧反而没那么危险但也有平台坑前面说的都是把变量地址当别的类型指针用反过来把指针转成整数这个方向在标准里是明确支持的属于实现定义而非未定义。也就是说编译器必须给出一个确定的结果只是不同平台结果可能不同。这个转换最常见的用途是打印地址、做哈希键、往日志里记录句柄值。坑在于宽度。unsigned long在 LP64 模型Linux、macOS 64 位下是 8 字节能装下 8 字节指针但在 LLP64 模型64 位 Windows下unsigned long仍然是 4 字节而指针是 8 字节转换时高 32 位直接被截断。这个差异导致同一份代码在 Linux 上打印地址正常在 Windows 上打印出来是个被砍掉一半的数字查起来极其费劲。标准给出的答案是stdint.h里的uintptr_t和intptr_t这两个类型保证能装下任何void *。打印的时候再配合inttypes.h的PRIxPTR宏格式串就不会写错。#include stdio.h #include stdint.h #include inttypes.h int main(void) { int cnt 42; uintptr_t addr (uintptr_t)cnt; printf(addr 0x% PRIxPTR \n, addr); printf(size of uintptr_t %zu\n, sizeof(uintptr_t)); return 0; }4.2 什么场景下这个转换是刚需举两个我实际碰到的例子。一个是把void *句柄塞进一个只接受long的通用容器里比如某些老式的事件通知机制回调参数只有一个long你只能把指针转成整数塞进去回调里再转回来。另一个是做内存对齐检查(uintptr_t)p % 8 0这种判断在写底层分配器时非常常见。这两个场景都有个共同前提转出去之后还要转回来所以类型宽度必须严格匹配一旦用了unsigned long而平台是 LLP64转回来的指针就是残缺的解引用直接崩。我个人的做法是代码库里全部用uintptr_t看到(unsigned long)ptr就顺手改掉改完跑一遍静态检查心里踏实。场景推荐写法不推荐写法打印地址printf(%p, (void *)p)printf(%lu, (unsigned long)p)指针存进整型句柄(uintptr_t)p(unsigned long)p整数还原指针(void *)(uintptr_t)v(void *)(unsigned long)v对齐判断((uintptr_t)p 7u) 0(unsigned long)p % 8 05. 完整实操从踩坑到写出正确版本5.1 需求把一个 int 的字节按不同宽度看假设我有一组传感器上报的 32 位原始数据raw需要按小端解析出一个unsigned long的数值做累积。新手写法很可能是*(unsigned long *)raw我在自己项目里最初也是这么写的直到在 64 位机器上发现累加结果莫名其妙偏大。正确的做法用memcpy下面这段可以直接抄#include stdio.h #include stdint.h #include string.h int main(void) { uint32_t raw 0x12345678u; uint64_t wide 0; /* 只搬 sizeof(wide) 和 sizeof(raw) 中较小的那个 其余字节保持 zero 初始化避免读到栈垃圾 */ memcpy(wide, raw, sizeof(raw)); printf(wide %#llx\n, (unsigned long long)wide); return 0; }这里有几个细节值得掰扯。wide初始化成 0 是必须的因为memcpy只写了低 4 字节高 4 字节保持初值如果wide是未初始化局部变量高位就是栈垃圾打印出来还是脏数据。另外memcpy的长度用的是sizeof(raw)而不是sizeof(wide)这是防越界读的关键写反了就是从 4 字节变量里读 8 字节。Linux 内核里大量使用这种模式get_unaligned、put_unaligned一类的辅助宏底层就是memcpy或者按字节拼装目的就是绕开对齐和别名两个坑。5.2 用联合体做重解释什么时候可以接受联合体是另一条常见路线C99 之后写入一个成员、读另一个成员在多数编译器上是被明确支持的C11 更是把这段行为写进了标准附件。#include stdio.h #include stdint.h union conv { uint32_t u32; uint8_t bytes[4]; }; int main(void) { union conv c; c.u32 0x12345678u; printf(%02x %02x %02x %02x\n, c.bytes[0], c.bytes[1], c.bytes[2], c.bytes[3]); return 0; }在小端机器上会打印78 56 34 12说明低字节落在低地址。这个写法用来观察字节序非常直观也是我做跨平台协议调试时的常用手段。但要提醒一句联合体方案的可移植性依赖编译器的支持程度跨编译器、跨标准版本时还是不如memcpy稳。如果这段代码要跑在多种工具链上我一般优先选memcpy只有在纯调试或者性能极敏感的固定平台上才用联合体。5.3 编译与验证的完整流程写完代码别急着交我习惯跑三步验证。第一步用-Wall -Wextra编一遍把int和unsigned long混用、格式串类型不匹配这类警告全清掉-Wformat对%lx配int这种情况是能报出来的。第二步加上-fsanitizeaddress,undefined跑一遍ASan 会抓住越界读UBSan 会抓住未对齐访问和类型不匹配的 UB这两个工具救过我很多次。第三步在目标平台上用-O2重跑对比结果是否一致一致了才敢往下走。gcc -O0 -g -Wall -Wextra -fsanitizeaddress,undefined demo.c -o demo_debug gcc -O2 -Wall -Wextra demo.c -o demo_release ./demo_debug ./demo_release这套流程走下来大概能拦住九成以上的指针强转问题。剩下那一成多半是跨平台宽度差异导致的只能在目标板子上实测。6. 常见问题对照与踩坑心得6.1 症状到根因的速查表下面这张表是我这几年攒下来的基本覆盖了*(unsigned long *)和(unsigned long)相关的绝大多数异常表现。出问题的时候对着表从症状反查比从头读代码快得多。症状高概率根因处理方式debug 正常release 数值错违反严格别名被优化掉换memcpy或临时加-fno-strict-aliasing验证打印地址少一半位数平台是 LLP64unsigned long只有 4 字节改用uintptr_tPRIxPTR目标板上访问直接进异常地址未按目标类型宽度对齐换字节拼装或memcpy高位总是 0 或总是垃圾读取宽度大于变量宽度越界读长度用sizeof(源变量)变量的值偶尔不对但逻辑没问题地址被取走后编译器放弃寄存器优化接受或重构非 bug负数转换后变成巨大正数有符号转无符号按模运算符合标准确认业务是否要这样6.2 几条用血换来的经验第一条看代码先看volatile有没有加对。如果一段指针强转是在访问硬件缺volatile的后果比缺类型匹配还严重因为它会导致编译器把重复的写操作合并掉硬件那边根本收不到。我在一个中断服务程序里排查过一整天才发现是漏了volatile。第二条任何(Type *)出现在普通变量的地址上都要停三秒。我自己定的规矩是在应用层代码里 grep 到*(加*)这种组合全部人工过一遍九成都能改成memcpy或者直接值转换。第三条别迷信我这台机器上能跑。C 语言的 UB 是按编译器、按优化级别、按平台分别表现的本地过了不代表线上过。要验证就得在目标环境下用目标编译选项跑。第四条cnt这种计数变量根本不需要任何指针转换。它就是一个值你要把它赋给unsigned long直接写unsigned long total cnt;就行编译器会做正确的符号扩展或零扩展。我见过有人为了避免符号扩展去写指针强转结果引入了三个新问题得不偿失。第五条跨平台代码一律用stdint.h里的定宽类型。uint32_t、uint64_t、uintptr_t这三个在写涉及指针和宽度的代码时应该成为默认选择unsigned long只在你明确知道目标平台宽度、或者对接硬件寄存器宽度时才用。这条规矩让我少加了无数个#ifdef _WIN32。最后分享一个我自己的习惯每次写完涉及指针转换的代码我会在注释里写清楚这里为什么必须强转比如该地址为芯片手册规定的控制寄存器宽度 32 位必须用 volatile 访问。写不出这句注释的基本就说明这个强转是不必要的回头改成值转换或者memcpy会更省事。代码是写给下一个接手的人看的而那个人往往就是三个月后的自己。