Let’s say you have the following program:
#include <stdio.h>
int main (int argc, char** argv)
{
int * p = (int *)argv[1];
printf("%d", *p);
if(p) {
puts("When life gives you lemons");
}
}
It is a simple program that does a couple things.
- Accepts an argument from the user
- Casts the input to pointer of type int, and assigns it to p
- Dereferences p and prints it to the screen
- Checks if p is not null, and if it isn’t output the string
At optimization level 2 and up(-O2 and -O3) the compiler will actually remove the null pointer check at step 4. This is because of the GCC option, -fdelete-null-pointer-checks. The point of this optimization is to remove redundancy in the code. If the program finds that the pointer was dereferenced earlier on in the code, it will assume that the pointer is not null.
In our case, on line 7, we dereference the pointer so that we can output it’s value onto the screen. Once the compiler sees this line, it will erase all null pointer checks that tests for p if the option is enabled. The compiler thinks that if we are dereferencing a variable then it must have an address that it is pointing to, and think that all future tests are redundant and unecessary.
In an attempt to show what is happening I will compile the program twice. Once with the option enabled and one without it:
gcc -o on Main.c -g -O2 -fno-builtin -fdelete-null-pointer-checks
gcc -o off keep_checks Main.c -g -O2 -fno-builtin -fno-delete-null-pointer-checks
Note that we are using optimization level 2 to compile our programs. This is because level 2 and up have the optimization options that -fdelete-null-pointer-checks depends on.
Let’s take a look at the section in the binary with the option enabled, using objdump:
objdump -f -s -d --source on.exe
00401c80 <_main>:
#include <stdio.h>
int main (int argc, char ** argv)
{
401c80: 55 push %ebp
401c81: 89 e5 mov %esp,%ebp
401c83: 83 e4 f0 and $0xfffffff0,%esp
401c86: 83 ec 10 sub $0x10,%esp
401c89: e8 e2 fc ff ff call 401970 <___main>
int * p = (int *)argv[1];
printf("%d", *p);
401c8e: 8b 45 0c mov 0xc(%ebp),%eax
401c91: 8b 40 04 mov 0x4(%eax),%eax
401c94: 8b 00 mov (%eax),%eax
401c96: c7 04 24 64 30 40 00 movl $0x403064,(%esp)
401c9d: 89 44 24 04 mov %eax,0x4(%esp)
401ca1: e8 3a ff ff ff call 401be0 <_printf>
if(p) {
puts("When life gives you lemons");
401ca6: c7 04 24 67 30 40 00 movl $0x403067,(%esp)
401cad: e8 36 ff ff ff call 401be8 <_puts>
}
401cb2: c9 leave
401cb3: c3 ret
401cb4: 90 nop
401cb5: 90 nop
401cb6: 90 nop
401cb7: 90 nop
401cb8: 90 nop
401cb9: 90 nop
401cba: 90 nop
401cbb: 90 nop
401cbc: 90 nop
401cbd: 90 nop
401cbe: 90 nop
401cbf: 90 nop
and now with the option disabled:
objdump -f -s -d --source off.exe
00401c80 <_main>:
#include <stdio.h>
int main (int argc, char ** argv)
{
401c80: 55 push %ebp
401c81: 89 e5 mov %esp,%ebp
401c83: 53 push %ebx
401c84: 83 e4 f0 and $0xfffffff0,%esp
401c87: 83 ec 10 sub $0x10,%esp
401c8a: e8 e1 fc ff ff call 401970 <___main>
int * p = (int *)argv[1];
401c8f: 8b 45 0c mov 0xc(%ebp),%eax
401c92: 8b 58 04 mov 0x4(%eax),%ebx
printf("%d", *p);
401c95: 8b 03 mov (%ebx),%eax
401c97: c7 04 24 64 30 40 00 movl $0x403064,(%esp)
401c9e: 89 44 24 04 mov %eax,0x4(%esp)
401ca2: e8 39 ff ff ff call 401be0 <_printf>
if(p) {
401ca7: 85 db test %ebx,%ebx
401ca9: 74 0c je 401cb7 <_main+0x37>
puts("When life gives you lemons");
401cab: c7 04 24 67 30 40 00 movl $0x403067,(%esp)
401cb2: e8 31 ff ff ff call 401be8 <_puts>
}
401cb7: 8b 5d fc mov -0x4(%ebp),%ebx
401cba: c9 leave
401cbb: c3 ret
401cbc: 90 nop
401cbd: 90 nop
401cbe: 90 nop
401cbf: 90 nop
We can see that in the binary with the option disabled, two lines are added.
401ca7: 85 db test %ebx,%ebx 401ca9: 74 0c je 401cb7 <_main+0x37>
This is the code that checks if the condition is true, and if it is, jumps into the code block. As you can see without the option enabled, the compiler will always check if p is null. With the option enabled, the compiler will skip that check entirely as if that piece of code was not there.
Differences Between the Binaries
When we compare the two binaries, we can see that there is almost no difference in file size. In fact, the binary with the option enabled produced a slightly larger binary. In terms of compile time, the difference is insignificant. The difference is very small, but the program with the option enabled slightly faster, since it does not have to validate the pointer.
Dangers of Using -fdelete-null-pointer-checks
The danger of enabling this option is that a pointer might point to nullsometime during run-time and if there is no validation for that pointer, it will cause the program to crash. Since we don’t receive an error message saying that a pointer is pointing to null, we will have a hard time trying to find the problem.
When to Use -fdelete-null-pointer-checks
Pointers should almost always be validated before performing operations with them. The only times I found where this option being enabled would benefit the program would be during Linux kernel development, and other highly specific projects. Most projects don’t need to have this option enabled, and because of this, if you are using optimization level 2 and up, I suggest using the option to disable this, -fno-delete-null-pointer-checks.