Chapter 10 written ahead of the code

A Native Executable

Chapter nine produces C text. This chapter turns that text into a binary you can run, and that gap is the whole chapter. It is smaller than you expect and it contains one decision that is a genuine security problem if you get it wrong.

$ cat hello.iz let x = 10; let y = 20; print x + y; $ ./build/izvor hello.iz 30 $ ls hello hello.iz
Read this before you start

Chapters 1 to 4 were written from code already in the repo. This one is written ahead of it. The instructions are exact and the C is written to compile, but I have not run it, so if something does not build, the mistake is probably mine and not yours.

Files you touch2
New functions2
New dependencies0
Traps3

Less than you would think. Everything hard is already done.

After chapter nine, codegen_program writes a complete C translation unit to a FILE *. If you point that at stdout you can read it. What you cannot do yet is run it.

To run it you need three things. Put the C somewhere on disk. Ask a C compiler to turn it into a binary. Deal sensibly with what happens when that fails. That is the entire chapter, and none of it touches the compiler you have spent nine chapters writing.

Everything here goes in src/main.c. The front end, the type checker, the semantic pass and the code generator all stay exactly as they are. That separation is worth noticing. The part of a compiler that talks to the operating system is small and it sits at the edge, and keeping it there is why the rest of the code has been testable all the way through.

Checkpoint

$ ./build/izvor --emit-c hello.iz

You should see the generated C on your terminal. Read it once more before you start. Everything in this chapter is plumbing around that text.

The obvious version of this step has a security hole in it, so do not write the obvious version.

src/main.c · above main · needs #include <unistd.h>

src/main.cadd 18 lines
1
/* write_temp_c - render the program as C into a fresh temp file.
2
   path_out must have room for at least PATH_MAX bytes. Returns 1 on
3
   success, and on success the caller owns the file on disk. */
4
static int write_temp_c(const NodeList *program, char *path_out, size_t cap) {
5
    int n = snprintf(path_out, cap, "/tmp/izvor-XXXXXX.c");

The six X characters are not decoration. mkstemps replaces them with something unpredictable. snprintf rather than strcpy so a small buffer truncates instead of overflowing.

6
    if (n < 0 || (size_t)n >= cap) {
7
        return 0;
8
    }
9
 
10
    int fd = mkstemps(path_out, 2);

mkstemps rather than mkstemp, and the 2 is how many characters at the end to leave alone, so the name keeps its .c suffix. clang decides what language a file is from its extension, so the suffix is not cosmetic.

11
    if (fd < 0) {

It creates the file and hands you an open descriptor in one step. That is the entire point of it, and it is what step 2's trap is about.

12
        fprintf(stderr, "error: cannot create a temporary file\n");
13
        return 0;
14
    }
15
 
16
    FILE *out = fdopen(fd, "w");

Wraps the descriptor in a stream so codegen_program can write to it. Do not close fd and reopen the path by name: that reintroduces exactly the gap the trap describes.

17
    if (out == NULL) {
18
        close(fd);
19
        return 0;
20
    }
21
 
22
    codegen_program(program, out);
23
    return fclose(out) == 0;

Check the result of fclose, do not just call it. Buffered writes are flushed here, so a disk that filled up reports the failure at this line and nowhere earlier. Ignoring it means handing clang a half written file.

24
}
Trap 1 of 3 · why not just call it /tmp/izvor.c

A fixed name breaks in two ways. The mild one is that two compiles running at the same time write over each other, and you get a binary built from someone else's program.

The serious one is that /tmp is writable by everyone on the machine. If your compiler always writes /tmp/izvor.c, any other user can create that path first as a symbolic link pointing at a file of theirs, or of yours. Your compiler then opens the link, follows it, and writes the generated C straight into whatever it points at, with your permissions. That is a real and very old class of bug, and it has a name, a symlink attack.

mkstemps closes it by generating an unpredictable name and creating the file atomically, meaning the check for whether it exists and the act of creating it happen as one indivisible step with no gap in between for anyone to slip into. You get back a descriptor to the file it just made, not a path to reopen. Use that descriptor.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

Clean build, and an unused function warning for write_temp_c until step 8 calls it.

The most important twenty lines in the chapter. There is an easier way to write this and it is wrong.

src/main.c · under write_temp_c · needs <sys/wait.h> and <spawn.h>

Trap 2 of 3 · never build a command string

The tempting version is one line:

snprintf(cmd, sizeof cmd, "clang -O2 %s -o %s", c_path, out_path); system(cmd);

system does not run your program. It hands your string to /bin/sh and asks the shell to interpret it. The shell then reads your filenames looking for things it cares about: spaces, quotes, semicolons, backticks, $(.

So a file called my program.iz silently becomes two arguments. Worse, a file called x; rm -rf ~/.iz becomes two commands, and your compiler runs the second one. The user chose that filename, so you have just executed a stranger's text as a shell command with their permissions.

The fix is to never involve a shell. Build an array of arguments and hand it to the operating system directly. Then a space in a filename is just a space in a filename, because nothing is parsing the string any more.

src/main.cadd 24 lines
1
/* run_clang - compile c_path into out_path. Returns 1 if clang
2
   succeeded. Never builds a shell command; see the note above. */
3
static int run_clang(const char *c_path, const char *out_path) {
4
    char *argv[] = {

An array, not a string. Each element is one argument and stays one argument no matter what is inside it.

5
        (char *)"clang",

Element zero is the program name, which is a convention every Unix program relies on. The casts are because this array is typed for an interface that predates const, and they are ugly and unavoidable.

6
        (char *)"-std=c11",
7
        (char *)"-O2",

Optimisation is clang's job, not yours. This single flag is most of what you get for choosing a C backend, and it is why chapter nine did not worry about emitting clever code.

8
        (char *)c_path,
9
        (char *)"-o",
10
        (char *)out_path,
11
        NULL

The NULL terminator is how the operating system knows where the list ends. Forget it and it reads off the end of the array, which is undefined behaviour and usually a crash inside the C library where it makes no sense.

12
    };
13
 
14
    pid_t pid;
15
    int rc = posix_spawnp(&pid, "clang", NULL, NULL, argv, environ);

posix_spawnp starts a new process running clang. The p on the end means it searches PATH for it, the same way your shell does, so you do not have to know where clang is installed. environ passes the environment through, which is how clang finds its own headers and libraries.

16
    if (rc != 0) {
17
        fprintf(stderr, "error: cannot run clang. Is it installed and on your PATH?\n");

This is a different failure from clang running and rejecting the code. Here clang never started. Telling the user which of the two happened saves them a lot of time.

18
        return 0;
19
    }
20
 
21
    int status;
22
    if (waitpid(pid, &status, 0) < 0) {

Wait for it to finish. Without this your compiler exits while clang is still running, and the binary may not exist yet when you try to run it.

23
        return 0;
24
    }
25
    return WIFEXITED(status) && WEXITSTATUS(status) == 0;

Step 4 is entirely about this line.

26
}

You need extern char **environ; declared above this function. It is not in any header on every platform, so declaring it yourself is the portable move.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

One line of code, and it is wrong in almost every tutorial that touches it.

The status that waitpid fills in is not an exit code. It is a packed word holding several different facts about how the process ended, and the exit code is only one of them, sitting in part of the bits.

A process can end in more than one way. It can return normally from main, which is what you expect. It can also be killed by a signal, which is what happens when clang segfaults, or runs out of memory, or you press Control C. Those are genuinely different outcomes and the status word encodes which one happened.

reading the statuswhy the order matters
1
WIFEXITED(status)

True only if the process returned normally. Ask this FIRST. If it is false, there is no exit code to read, and reading one anyway gives you a number that means nothing.

2
WEXITSTATUS(status)

The actual value the process returned. Only meaningful when the line above is true, which is why they are joined with && rather than checked separately.

3
status == 0

The wrong test, and the common one. It happens to work when clang succeeds, because a clean normal exit does pack down to zero. It quietly misreports every other case, and it reads as correct, which is what makes it worth naming.

So the test is: did it exit normally, and was that exit zero. Anything else is a failure, including clang being killed halfway through, which leaves a truncated binary on disk that would otherwise look like a successful build.

Checkpoint

No code. Reread line 25 of run_clang and make sure you can say what each half of it is asking.

This is a message about how you handle being wrong, and it says more about a compiler than most of its features.

Think about what has already happened by the time clang runs. The parser accepted the program. The type checker agreed every expression made sense. The semantic pass resolved every name and checked every call. Only then did chapter nine turn the tree into C.

So if clang rejects that C, the user did not make a mistake. You did. Your code generator emitted something invalid, and the only person who can fix it is you. The message has to say that, because the alternative is a user staring at a C error about a symbol called iz_total in a file they never wrote and have no way to find.

src/main.cthe message when run_clang returns 0
1
fprintf(stderr,

Not diag_error. That reports a mistake at a position in the user's source, and there is no position here, because the fault is not in their program.

2
    "internal error: izvor generated C that clang would not accept.\n"
3
    "This is a bug in izvor, not in your program.\n"

Say it plainly. A user who has been told the fault is not theirs stops hunting through their own code, which is the single most useful thing this message does.

4
    "The generated C has been kept at %s\n"

Keep the temp file on this path, whatever --keep-c says. The evidence is the file, and deleting it at the exact moment it becomes useful is a small cruelty.

5
    "Please report it with that file attached:\n"
6
    "  https://github.com/levimackay/izvor/issues\n", c_path);

Checkpoint

To see it, break your code generator on purpose. In emit_expr, emit + where you meant * and leave off a closing parenthesis, then compile anything. Put it back afterwards.

By default clang writes its complaints straight to the terminal, past you entirely.

src/main.c · inside run_clang, before the spawn

The fourth argument to posix_spawnp, the one currently NULL, is a list of file actions: things to do to the child's file descriptors before it starts. Use it to point clang's standard error at a file of yours.

src/main.cadd 6 lines, and pass &actions instead of the second NULL
1
    posix_spawn_file_actions_t actions;
2
    posix_spawn_file_actions_init(&actions);
3
    posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, log_path,

Descriptor 2 is standard error, always. This says: before clang starts, open this file and make it descriptor 2. clang then writes its diagnostics there without knowing anything has changed.

4
                                     O_WRONLY | O_CREAT | O_TRUNC, 0600);

0600 means only you can read it. The file may contain fragments of the user's program, so it should not be readable by everyone on the machine.

5
 
6
    posix_spawn_file_actions_destroy(&actions);

After the spawn, on every path out including the failure ones. It is a small allocation and leaking it in the error path is the classic version of this mistake.

Then, when clang fails, read that file and print it under a heading that makes clear it came from clang and not from izvor. Something like clang said: on its own line. Without that heading, two error formats blur into one and the reader cannot tell which tool is talking.

Checkpoint

Break the code generator again. You should see izvor's internal error message first, then clang's output clearly marked as clang's.

One call and one flag, and the flag matters more than it looks.

src/main.c · after the build succeeds

src/main.cadd 5 lines
1
    if (keep_c) {
2
        fprintf(stderr, "generated C kept at %s\n", c_path);

To stderr, not stdout, so it never mixes into output someone is piping somewhere.

3
    } else {
4
        unlink(c_path);

Deletes the file. Not checked, deliberately: if it fails there is nothing sensible to do and nothing the user can act on, and a stray file in the temp directory is not worth a message.

5
    }
Trap 3 of 3 · do not skip the flag

It would be easy to always delete the file and move on. Do not.

The entire argument for generating C rather than machine code is that the intermediate is something a person can read. A compiler that generates readable C and then deletes it before anyone can look has thrown away the reason it was built that way. When you are debugging your own code generator, and you will be, reading the C is the fastest way to see what went wrong.

Checkpoint

$ ./build/izvor --keep-c hello.iz generated C kept at /tmp/izvor-a7Bq2x.c 30

Four modes. Decide them now, because changing a command line after people use it is worse than changing a language feature.

src/main.c · usage and the top of main

the shape4 modes
1
izvor prog.iz

Compile and run. The default, because it is what you want nine times out of ten while writing a program.

2
izvor -c prog.iz

Compile to a binary and stop. -c is what every C compiler on earth uses for roughly this, so borrowing it costs a reader nothing.

3
izvor --emit-c prog.iz

Print the generated C and stop, from chapter nine.

4
izvor -e "1 + 2"

The inline form from chapter three. Keep it, it is useful for a quick check.

Now the output name. Deriving it from the input is better than always writing a.out, for one practical reason: compile two programs in a directory and a.out means the second silently destroys the first. So hello.iz produces hello.

src/main.cderiving the name, 11 lines
1
static void output_name(const char *input, char *out, size_t cap) {
2
    const char *base = strrchr(input, '/');

Find the last slash, so src/hello.iz becomes hello and lands in the current directory rather than next to the source. Either is defensible. Pick one and say so in the usage text.

3
    base = (base == NULL) ? input : base + 1;
4
 
5
    const char *dot = strrchr(base, '.');

The LAST dot, not the first. my.test.iz should become my.test, and strchr would give you my.

6
    size_t len = (dot == NULL) ? strlen(base) : (size_t)(dot - base);
7
 
8
    if (len == 0 || len >= cap) {

A file literally called .iz has an empty name. Without this you write a binary called nothing, or overflow the buffer. Both are the kind of edge case the fuzzer from chapter three taught you to expect.

9
        snprintf(out, cap, "a.out");
10
        return;
11
    }
12
    snprintf(out, cap, "%.*s", (int)len, base);

%.*s again, the same trick as printing a token in chapter one. Copy exactly this many characters and stop.

13
}

Checkpoint

$ ./build/izvor usage: izvor <file.iz> compile and run izvor -c <file.iz> compile only izvor --emit-c <file.iz> print the generated C izvor -e "<expression>" evaluate one expression

Nothing new to write. This is the run that closes the loop chapter one opened.

Put the three lines in a file. They are the same three lines the course has been aiming at since the first page, and they have not changed.

Checkpoint

$ cat hello.iz let x = 10; let y = 20; print x + y; $ ./build/izvor -c hello.iz $ file hello hello: Mach-O 64-bit executable arm64 $ ./hello 30

Run ./hello once more and notice what is not happening. Your compiler is not running. clang is not running. Nothing is interpreting anything. That is a native binary holding machine code, and every part of the path from that text to those instructions is something you wrote or deliberately chose to delegate.

Hand the binary to someone else on the same kind of machine and it runs there too, with no izvor installed.

Every other test in this course checks one stage. This one checks all of them at once, and it is the only test that can fail for a reason none of the others would catch.

tests/golden/ · a new runner, not a C file

Write it as a shell loop in the Makefile rather than a C test, because it needs to compile a program, run the resulting binary, and compare what it printed. That is exactly what a shell is good at, and doing it from C means reimplementing a shell badly.

Makefilea new target, about 14 lines
1
e2e: $(BUILD)/izvor

End to end. Keep it separate from test at first, since it is slower than everything else put together, then fold it into test once you trust it.

2
	@for f in tests/e2e/*.iz; do \

That has to be a real tab character, not spaces. It is the one thing about make that catches everybody.

3
		./$(BUILD)/izvor -c "$$f"; \

Doubled dollar signs, because make eats a single one before the shell ever sees it. No output flag, because step 8 decided the name comes from the input, so tests/e2e/loop.iz produces loop in the current directory.

4
		name=$$(basename "$$f" .iz); \

Work out the same name output_name just produced, so the next line knows what to run.

5
		"./$$name" > "$(BUILD)/e2e.actual"; rm -f "./$$name"; \

Run it, capture what it printed, then delete the binary so the repo does not fill up with them.

6
		diff -u "$${f%.iz}.expected" "$(BUILD)/e2e.actual" || exit 1; \

Same shape as the golden tests from chapter three, except what is being compared is the output of a program your compiler produced rather than the output of your compiler.

7
	done

Start with four: the milestone program, one with a function and a call, one with a loop that accumulates, and one with an if that takes each branch. Four is enough to catch a code generator that emits plausible C with the wrong meaning.

Here is why this test is different from everything before it. A unit test on the parser proves the parser works. A golden test proves an error message reads well. Neither can catch a code generator that produces C which compiles cleanly and computes the wrong answer, because nothing in those tests ever runs the program. This one does, and it is the only place a mistake in the meaning of the generated code has nowhere to hide.

Checkpoint

$ make e2e e2e: 4 programs compiled, ran, and matched

Worth writing into the architecture notes while it is fresh, because these are the first questions anyone will ask you about it.

honest limitations5
1
no optimisation control

-O2 is hardcoded. A real compiler lets you choose, and wants a debug mode that keeps names and line numbers intact so a debugger can map machine code back to izvor source. That mapping is real work and it is not here.

2
one file only

Everything goes into one translation unit every time. No separate compilation, so a one line change recompiles everything, and no linking two izvor programs together.

3
no libraries

The generated C includes stdio.h for printf and nothing else. There is no way for an izvor program to call anything the language does not already provide.

4
clang must be installed

The honest cost of this backend. izvor is not a self-contained compiler, it is a front end plus a very good borrowed back end, and a machine without a C compiler cannot run it.

5
Unix only

posix_spawnp, waitpid, mkstemps and unlink are all POSIX. Windows needs a different implementation of this one file, which is a reasonable amount of work and touches nothing else.

Write these into docs/ARCHITECTURE.md under known limitations, next to the parser recursion depth and the unchecked arithmetic from the earlier chapters. Being able to list what your compiler does not do, precisely, is worth more in a conversation about it than any feature you could add instead.

That is the last step. The language reads source, checks it properly, tells you clearly when it cannot, and produces a native binary. Chapters 11 and 12 add arrays and structs and then concurrency, but the pipeline is complete right here.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test $ make e2e $ make fuzz