找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4414

积分

0

好友

568

主题
发表于 1 小时前 | 查看: 2| 回复: 0

一个 int 数组声明了 8 个元素,代码里写下 arr[20] = 0x55,编译器一声不吭,程序照跑不误。同样的逻辑丢到 PC 上用 gcc 编译,跑起来大概率当场 Segmentation fault 退出。同一段 C 代码,同一个越界动作,单片机装作什么都没发生。

这不是单片机脾气好,而是它连"发现越界"这个能力都没有。

大部分初学者第一次碰到这种现象都会懵:明明数组只有 8 个位置,凭什么写第 20 个位置不报错?更诡异的是,程序有时候还能"正常"跑很久,直到某天在一个八竿子打不着的函数里莫名其妙崩掉。要把这件事说清楚,得从 C 语言里数组访问到底被编译成了什么开始,一路挖到寄存器和总线。

STM32数组越界错误传播示意图

下面的分析以 ARM Cortex-M 内核为基准,用 STM32 系列的地址布局做参照。凡是和内核型号强相关的结论(对齐、异常、MPU),都会点明适用范围。

一、越界的本质

先把一个误解掐掉:数组越界不是"错误",在 C 语言的世界里它连"非法操作"都算不上。C 标准把它归类为 undefined behavior,未定义行为。意思是标准根本不规定越界之后会发生什么——可以崩,可以不崩,可以把隔壁变量改了,也可以让编译器假装这行代码不存在。

这里藏着第一个盲区。很多人以为"未定义行为"等于"运行时会出错"。恰恰相反。未定义行为的真正含义是:编译器有权假设它永远不会发生,并基于这个假设去做优化。所以在 -O2 优化等级下,编译器看到一段它能证明会越界的代码,可能直接把整段逻辑删掉,因为按标准这段代码"不可能被执行到"。

数组越界之所以不报错,根子在于 C 从设计之初就选择了零开销抽象。数组访问这个动作,编译出来必须是一条干净的存取指令,中间不允许插入任何检查。检查意味着比较加分支,每次访问都多花几个时钟周期。C 语言把这个成本连同风险一起,全部甩给了程序员。

二、数组名就是地址

要理解越界为什么无法被拦截,先得看清 arr[i] 这个写法在编译器眼里是什么。

C 标准里有一条硬规定:表达式 E1[E2] 完全等价于 *(E1 + E2)。数组下标访问本质上就是指针加法再解引用。写 arr[i] 和写 *(arr + i),编译出来的机器码一模一样。

arr 是什么?在表达式里,数组名会退化成指向首元素的指针,也就是 &arr[0]。所以 arr[i] 展开后是:取数组首地址,加上 i 乘以单个元素的字节数,得到目标地址,然后往那个地址读或写。

关键在于这个"加法"。它是纯粹的地址运算,编译器从头到尾没有把数组的长度 8 带进这个运算里。长度这个信息,只存在于编译阶段的类型系统中——int[8] 这个类型让 sizeof(arr) 能算出 32 字节。可一旦到了运行时,机器码里只剩下一个起始地址加一个偏移。数组有多大,运行时没有任何地方记录。

更狠的一点:数组一旦作为参数传进函数,连编译期的长度信息都彻底丢了。函数签名里的 int arr[]int *arr 完全等价,形参接收到的只是一个裸指针。函数内部想知道数组多长?做不到。这就是为什么 C 里传数组永远要额外带一个长度参数。

三、汇编层的真相

光说不够,直接看编译产物。拿一段最简单的越界写:

int g_arr[8];

void write_oob(int i, int v)
{
    g_arr[i] = v;
}

在 Cortex-M4(ARMv7-M)上,开优化后核心指令就两三条:

write_oob:
    LDR   r2, =g_arr           ; r2 = 数组首地址
    STR   r1, [r2, r0, LSL #2] ; 把 v 存到 (首地址 + i*4)
    BX    lr                   ; 返回

盯着那条 STR 看。它做的事情是:把 r0(也就是下标 i)逻辑左移 2 位,等于乘以 4,因为一个 int 占 4 字节;把结果加到基址 r2 上,得到目标地址;把 r1 里的值写进去。

这条指令里没有任何比较,没有任何分支,没有任何对 i 范围的判断。i 传进来是 3 就写第 3 个元素,是 20 就写第 20 个元素,是 100000 就往一个离数组十万个 int 开外的地址上写。CPU 忠实执行,绝不多问。

指令集在这里帮不上任何忙。ARMv7-M 的 STR 支持寄存器偏移带移位,一条指令搞定寻址,效率极高。代价就是它天生不知道边界为何物。

换到 Cortex-M0/M0+(ARMv6-M)上会有点区别。M0 的指令编码更精简,STR 不支持带移位的寄存器偏移,编译器得拆成两步:

    LSLS  r0, r0, #2      ; i * 4
    LDR   r2, =g_arr
    STR   r1, [r2, r0]    ; 首地址 + 偏移

指令多了一条,寻址方式换了个写法,但本质完全一样——照样没有边界检查。指令集越简单,越不可能凭空长出保护逻辑。

四、内存是一整块

汇编层证明了 CPU 不检查。可 PC 上同样是 x86 指令、同样不带边界检查,为什么越界经常会崩?区别在硬件的地址管理机制上。

跑操作系统的 CPU,比如 Cortex-A 系列或者 x86,内部有一个 MMU,内存管理单元。程序看到的是虚拟地址,MMU 负责把虚拟地址翻译成物理地址。翻译要查页表,页表里记录了哪些地址是有效的、可读的、可写的。访问一个没有映射的虚拟地址,MMU 当场触发缺页异常,内核收到后给进程发 SIGSEGV,程序被杀掉。栈底下还专门留一个不可访问的保护页,栈一旦冲过头就撞上去。

Cortex-M 内核根本没有 MMU。这是 M 系列和 A 系列最核心的分界线之一。M 内核用的是物理地址,程序里写的地址就是总线上跑的地址,中间没有翻译,没有页表,没有权限检查这一层。

整个地址空间是平的、连续的一整块。以 STM32 为例:Flash 从 0x08000000 开始,SRAM 从 0x20000000 开始,外设寄存器从 0x40000000 开始,内核的系统控制区从 0xE0000000 开始。这些区域首尾相接,中间没有任何软件层的隔离。

现在回到越界。数组假设放在 SRAM 里,首地址 0x20001000。越界写 arr[20],算出来的目标地址是 0x20001050。这个地址落在 SRAM 范围内,是一块真实存在、可读可写的物理内存。总线拿到写请求,二话不说把数据怼进去。硬件层面这就是一次再合法不过的写操作,和写数组内部没有任何区别。没有缺页,没有异常,没有任何机制觉得这里有问题。

这才是单片机不报错的物理根源:越界写的地址,往往还是一块正常的 RAM。

顺便纠正一个流传很广的说法:PC 上越界"一定会崩"。这话不对。PC 靠 MMU 抓越界,抓的是页边界。一页通常 4KB,只有当越界跨过页边界、踩到一个没有映射的页时,缺页异常才会触发。越界只有几个、十几个字节,还落在当前这一页里的话,PC 照样悄无声息地把隔壁数据改坏,跟单片机没两样。所以越界写小数组时 PC 也经常不崩,只是它比单片机多了一层页级别的兜底,撞大了才拦得住。单片机连这层兜底都没有,仅此而已。

五、越界写去哪了

既然写进去了,那被覆盖的到底是什么?这取决于数组存在哪。

局部数组放在栈上。Cortex-M 用满递减栈,栈指针从高地址往低地址生长。函数被调用时,编译器在栈上给局部变量分配空间,同时把返回地址(LR 的值)也压在栈里。局部数组的下标增大,地址往高处走,方向正好朝着别的局部变量、被保存的寄存器,以及那个返回地址。

看这段:

void func(void)
{
    int buf[4];
    buf[8] = 0xDEADBEEF;   // 越界,冲出 buf 之外
}

buf 只有 4 个 int,占 16 字节。buf[8] 的地址在 buf 首地址往上 32 字节处,早就冲出了这个数组的范围,大概率正好落在栈上保存返回地址的位置。这一写,返回地址就被改成了 0xDEADBEEF。

函数执行到结尾,通过 POP {PC} 或者 BX lr 返回。它从栈里取出那个被污染的返回地址,加载进 PC,然后 CPU 就跳到 0xDEADBEEF 去取指令。这个地址是不是有效代码区完全看运气。跳过去大概率取到非法指令或者访问了无效地址,这时候才终于触发异常。

全局数组和静态数组放在 .data.bss 段。链接器把这些变量一个挨一个排在 SRAM 里。越界写一个全局数组,覆盖的就是紧挨着它的下一个全局变量。变量的排列顺序由链接器决定,源码里声明的先后顺序不保证就是内存里的顺序。所以越界改的到底是哪个变量,光看代码根本猜不出来,得去翻 map 文件。

堆上的数组更麻烦。malloc 分配的内存块前后带着堆管理器的元数据,记录块大小、空闲链表指针这些。越界写很容易踩坏相邻块的元数据,结果不是当场崩,而是下一次 mallocfree 的时候,堆管理器读到被污染的元数据,逻辑彻底乱套。

六、崩溃为什么延迟

上面三种情况有个共同点:出问题的地方,和真正崩溃的地方,隔着十万八千里。

越界那一瞬间,硬件毫无反应,程序继续往下跑。被污染的那块内存,可能过好几个函数、好几毫秒之后才被读出来用。等到那时候程序行为出错甚至彻底崩溃,现场早就变了。调试器停下来指着的那行代码,和真正写坏内存的那行代码,八竿子打不着。

这就是内存越界最要命的地方——错误的暴露被延迟,而且发生了位移。你在 A 函数里越界写坏了一个全局变量,程序在 B 函数里读这个变量做判断,走进了错误的分支,最后在 C 函数里因为一个空指针崩掉。debug 从 C 往回查,查到 B,怎么看 B 的逻辑都没错,因为坏的根本不是逻辑,是数据。真凶 A 藏在几百行之外,压根不在你的视线里。

还有更折磨人的情况:越界写坏的那块内存,恰好是一块暂时没人用的空间,或者很快又被别的代码正常覆盖掉了。这种时候程序表面上一切正常,测试也测不出来。它就是一颗埋着的雷,等到某次内存布局稍微一变,或者某个执行路径第一次被走到,才突然引爆。

单片机不报错,不代表没出错。它只是把出错的时间点和地点,全给你打乱了。

七、MPU 救不了你

有人会问:Cortex-M 不是有 MPU 吗?内存保护单元,听名字就是干这个的。

MPU 确实是 M 系列的可选硬件保护部件。Cortex-M3/M4/M7 大多带 MPU,能配置最多 8 个区域(M7 可以到 16 个),Cortex-M0+ 也有可选的 MPU,而最早的 Cortex-M0 则没有。它的工作方式是:把地址空间划成若干个区域,每个区域设定访问权限,比如只读、禁止访问、禁止取指。访问违反了权限,硬件触发 MemManage 异常。

相关寄存器都在系统控制区:MPU_CTRL 在 0xE000ED94 控制总开关,MPU_RNR 在 0xE000ED98 选区域号,MPU_RBAR 在 0xE000ED9C 设区域基址,MPU_RASR 在 0xE000EDA0 设区域大小和权限。

问题来了,MPU 保护的粒度是"区域",不是"变量"。它的区域最小 32 字节,而且大小必须是 2 的幂,基址还得对齐。一个数组和它隔壁的变量,通常都待在同一个 RAM 区域里。数组越界写到隔壁变量身上,两个地址在 MPU 眼里属于同一个区域、同一套权限,MPU 根本不认为这是违规。它拦不住。

MPU 能拦住的,是那种越界越出天际的情况——下标大到把地址算飞了,飞出了 RAM 区域,撞进一块被 MPU 标成禁止访问的地方。这种时候 MemManage 异常才会触发。可日常那种下标越界几个、十几个元素的 bug,越界后的地址还稳稳待在合法 RAM 里,MPU 全程视而不见。

而且还有个前提:MPU 复位后默认是关闭的。不主动配置 MPU_CTRL、不划分区域、不使能异常,它就是块死电路。绝大多数裸机工程压根没碰过 MPU。带 MPU 保护的 RTOS,比如 FreeRTOS-MPU,用它来做任务之间的隔离,也是区域级别的隔离,同样管不了任务内部一个数组越界踩另一个数组。

所以别指望 MPU 帮你抓数组越界。它不是为这个设计的。

八、编译器的沉默

既然运行时抓不住,编译期能不能提前警告?

能,但范围极其有限。GCC 的 -Warray-bounds 选项,在 -O2 及以上优化等级下会启用(-Wall 也会带上),它能揪出下标是编译期常量的越界。比如你直接写 arr[20],而数组只有 8 个元素,编译器通过静态分析就能算出这次访问必然越界,给你一条警告。

但现实中的越界,下标几乎都是运行时变量:arr[i]arr[idx]buf[len - 1]。这个 i 从哪来?可能是函数参数,可能是循环计数器,可能是某个传感器返回的值。编译器做静态分析时,根本没法确定运行时这个变量会取到什么范围。它无法证明会越界,就不会警告。

这就是编译器的沉默地带。它不是不想帮忙,是信息不足。数组的长度它知道,可下标的值它算不出来,两者对不上,检查就无从谈起。而绝大多数真实的越界 bug,恰好全都发生在这个沉默地带里。

有几个更重的工具能做运行时检查。-fsanitize=address,也就是 AddressSanitizer,会在每块内存前后插入红区,用一套影子内存记录每个字节能不能访问,然后在每条读写指令前插入检查代码。它抓越界又快又准。但代价是内存占用翻着倍涨,影子内存本身就要吃掉正常内存的八分之一,再加上大量插桩代码。这套东西在几十 KB RAM 的单片机上根本跑不起来,基本只能用在 PC 端或者仿真环境里测逻辑。

九、栈金丝雀机制

针对栈上数组溢出,有一个相对轻量的防护手段,叫栈保护,编译选项是 -fstack-protector。它的核心是在栈上局部数组和返回地址之间,插入一个已知的标记值,业界叫它 canary,栈金丝雀。

机制很直接。函数入口处,编译器生成代码把一个固定的哨兵值写到局部数组和返回地址之间的位置。这个值存在一个全局变量 __stack_chk_guard 里。函数返回之前,编译器再生成代码,检查那个位置的值有没有被改动。如果一个数组连续溢出,往高地址方向覆盖,它想踩到返回地址,就必然先踩到中间这个哨兵值。返回前一检查,发现哨兵被改了,立刻调用 __stack_chk_fail 中止程序,不让那个被污染的返回地址有机会加载进 PC。

这招能挡住的是连续的、朝着返回地址方向的栈缓冲区溢出,也就是最经典的那类栈溢出攻击面。但它有明显的死角。哨兵只放在数组和返回地址之间,如果越界的下标是负的,或者往低地址方向踩,或者跳着踩、直接算出一个远处的地址,绕过了哨兵所在的位置,检查照样失效。它也完全管不了全局数组和堆数组的越界。

在单片机上用栈保护还得自己搭台子。__stack_chk_guard 这个哨兵值和 __stack_chk_fail 这个失败处理函数,标准库在裸机环境里往往没提供,得工程师自己实现。哨兵值最好用一个运行时生成的随机数,写死成固定值就失去了防护意义。加上它每个函数都要多几条指令做写入和检查,有一定开销,很多对性能和体积敏感的工程干脆不开。

十、怎么逼它现形

硬件不管,编译器多数时候沉默,重量级工具又跑不动。这道题最后还是落回到写代码的人身上。逼数组越界现形,靠的是一整套习惯,不是某个开关。

访问数组前,下标该自己比。凡是下标来自外部输入、通信数据、传感器读数这类不可控来源,进数组之前先跟数组长度比一次,越界的直接挡回去。这一句 if 判断,就是 C 语言帮你省掉的那次检查,现在手动补回来。别嫌它啰嗦,它换来的是错误在发生点当场暴露,而不是位移到几百行外。

写法上有个细节容易翻车。下标变量如果声明成了有符号的 int,只判断上界是不够的:

#define ARR_LEN 8
int  arr[ARR_LEN];

void safe_write(int i, int v)
{
    if (i < 0 || i >= ARR_LEN)   // 下界和上界都得卡
        return;
    arr[i] = v;
}

只写 if (i >= ARR_LEN),漏掉 i < 0 这半句,一个负下标就能算出数组前方的地址,照样越界,而且往栈的更深处或者前一个变量身上踩。更稳的做法是干脆把下标类型定成无符号的 uint32_t,负数自动变成一个巨大的正数,一次上界判断就全挡住了。这些都是编译器不会替你操心、手册也不会专门提醒的细节,全靠写代码时自己盯住。

数组长度别用魔数。用 sizeof(arr) / sizeof(arr[0]) 在编译期算出元素个数,或者定义成宏统一管理。手写 8、16 这种数字散落在代码各处,改数组大小时漏改一处,就是一个现成的越界。

数组传参必带长度。前面说过,数组一进函数就退化成裸指针,长度信息彻底丢失。函数内部想安全访问,唯一的办法就是调用方把长度一起传进来。这是 C 语言的硬约束,绕不过去。

把 MPU 用在刀刃上。虽然它抓不了细粒度越界,但可以配一个策略:在地址空间里划一小块标成禁止访问,让某些异常指针访问撞上去立刻触发 MemManage 异常。配合把栈顶下方一小段设成不可写,也能在栈冲出边界时提前拦一道。这需要主动配置 MPU_CTRL 和各区域寄存器,还要在 SHCSR(0xE000ED24)里使能 MemManage 异常。

最后,把异常处理程序写扎实。一旦真崩了,别让它 while(1) 空转。在 HardFault 或 MemManage 的异常处理里,把关键寄存器抓出来看:

void HardFault_Handler(void)
{
    uint32_t cfsr = *(volatile uint32_t *)0xE000ED28; // 可配置错误状态
    uint32_t hfsr = *(volatile uint32_t *)0xE000ED2C; // 硬错误状态
    uint32_t mmar = *(volatile uint32_t *)0xE000ED34; // MemManage 出错地址
    uint32_t bfar = *(volatile uint32_t *)0xE000ED38; // 总线出错地址

    (void)cfsr; (void)hfsr; (void)mmar; (void)bfar;
    while (1);   // 打断点,看上面四个值
}

CFSR 在 0xE000ED28,是一个 32 位寄存器,低 8 位是 MemManage 状态,中间 8 位是总线错误状态,高 16 位是用法错误状态。如果 CFSR 的 BFARVALID 位(第 15 位)置起,说明 BFAR 里的地址就是这次总线错误访问的地址;如果 MMARVALID 位(第 7 位)置起,MMAR 里就是 MemManage 出错的地址。这两个地址寄存器是反查越界现场最直接的线索。它给不了你越界的源头,但至少告诉你 CPU 最后死在了哪个地址上,比对着 map 文件能缩小很大一片排查范围。

数组越界在单片机上从来不是"会不会报错"的问题。硬件没有报错的机制,C 语言没有报错的义务,编译器没有报错的信息。它只会安静地把某块内存改坏,然后等一个你最想不到的时刻,在一个你最想不到的地方,把这笔账连本带利算给你。能拦住它的,始终只有写下每一次数组访问时的那一分警觉。

这类"不报错但暗中埋雷"的问题在嵌入式开发中还有很多,欢迎来云栈社区和更多开发者一起交流排坑经验。




上一篇:结构体、联合体与位域:嵌入式协议帧与GPIO寄存器实战
下一篇:贝索斯儿子每月50万美元生活费被后妈砍了?聊聊这瓜
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-31 07:25 , Processed in 0.809749 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表