Memory corruption bugs have always been a common problem in large C programs [...]
But there was a time when large programs, including operating systems, were written in assembly, not C.
You're aware that there are other languages that were quite common already early on? Like COBOL, FORTRAN or PL/1?
Was memory corruption bugs a common problem in large assembly programs?
This depends of course on multiple factors, like
- the Assembler used, as different assembler programs offers different level of programming support.
- program structure, as especially large programs adhere to checkable structure
- modularisation, and clear interfaces
- the kind of program written, as not every task requires pointer fiddling
- best practice style
A good assembler does not only make sure that data is aligned, but as well offers tools to handle complex data types, structures and alike in abstract fashion, reducing the need to 'manually' calculate pointers.
An assembler used for any serious project is as always a macro assembler (*1), thus capable to encasule primitive operations into higher level macro instructions, enabling a more application centric programming while avoiding many pitfalls of pointer handling (*2).
Program types are also quite influential. Applications usually consist of various modules, many of them can be written almost or complete without (or only controlled) pointer usage. Again, usage of tools provided by the assembler is key to less faulty code.
Next would be best practice - which goes hand in hand with many of the before. Simply do not write programs/modules that need multiple base registers, that hand over large chunks of memory instead of dedicated request structures and so on...
But best practice starts already early on and with seemingly simple things. Just take the example of a primitive (sorry) CPU like the 6502 having maybe a set of tables, all adjusted to page borders for performance. When loading the address of one of these tables into a zero page pointer for indexed access, usage of the tools the assembler would mean to go
Quite some programs I've seen rather go
(or worse, if on a 65C02)
The usual argumentation is 'But it is aligned anyway'. Is it? Can that be guaranteed for all future iterations? What about some day when address space get tight and they need to be moved to non aligned addresses? Plenty of great (aka hard to find) errors to be expect.
So Best practice again brings us back to using the Assembler and all the tools it offers.
Do not try to play Assembler instead of the Assembler - let him do his job for you.
And then there is the runtime, something that applies to all languages but is often forgotten. Beside things like stack checking or bounds check on parameters, one of the most effective ways to catch pointer errors is simply locking the first alnd last memory page against write and read (*3). It not only catches the all beloved null pointer error, but as well all low positive or negative numbers which are often result of some prior indexing going wrong. Sure, Runtime is always the last resort, but this one is an easy one.
Above all, maybe the most relevant reason is
in reducing chances of memory corruption by reducing the need to handle with pointers at all.
Some CPU structures simply require less (direct) pointer operations than other. There is a huge gap between architectures that include memory to memory operations vs. such who don't, like accumulator based load/store architectures. The inherently require pointer handling for anything larger than a single element (byte/word).
For example to transfer a field, let's say a customer name from around in memory, a /360 uses a single MVC operation with addresses and transfer length generated by the assembler from data definition, while a load/store architecture, designed to handle each byte separate, has to set up pointers and length in registers and loop around a moving single elements.
Since such operations are quite common, resulting potential for errors is as well common. Or, on a more generalized way it can be said that:
Programms for CISC processors are usually less prone to errors than such written for RISC machines.
Of course and as usual, everything can be screwed up by bad programming.
And how did it compare to C programs?
Much the same - or better, C is the HLL equivalent of the most primitive CPU ISA, so anything offering higher level instructions will fair better.
C is inherently a RISCy language. Operations provided are reduced to a minimum, which goes with a minimum ability for check against unintended operations. Using unchecked pointers is not only standard but required for many operations, opening many possibilities for memory corruption.
Take in contrast a HLL like ADA, here it's almost impossible to create pointer havoc - unless it's intended and explicit declared as option. A good part of it is (like with the ISA before) due higher data types and handling thereof in a typesafe manner.
For the experience part, I did most of my professional life (>30y) in Assembly projects, with like 80% Mainframe (/370) 20% Micros (mostly 8080/x86) - plus private a lot more :) Mainframe programming covered projects as large as 2+ millions LOC (instructions only) while micro projects kept around 10-20k LOC.
*1 - No, something offering away replacing text passages with premade text is at best some textual preprocessor, but not a macro assembler. A macro assembler is a meta tool to create the language needed for a project. It offers tools to tap the information the assembler gathers about the source (field size, field type, and many more) as well as control structures to formulate handling, used to generate appropriate code.
*2 - It's easy to bemoan that C wasn't fit with any serious macro capabilities, it would not only removed the need for many obscure constructs, but as well enabled much advancement by extending the language without the need to write a new one.
*3 - Personally I prefer to make page 0 only write protected and fill the first256 bytes with binary zero. That way all null (or low) pointer writes still result in a machine error, but reading from a null pointer returns, depending on the type, a byte/halfword/word/doublewort containing zero - well, or a null-string :) I know, it's lazy, but it makes life much more if easy one has to incooperate other peoples code. Also the remaining page can be used for handy constant values like pointers to various global sources, ID strings, constant field content and translate tables.